跟随主后台优化

This commit is contained in:
李琦
2026-08-13 19:02:16 +08:00
parent b24e6229c0
commit f42c36c430
114 changed files with 8257 additions and 1115 deletions

View File

@@ -47,7 +47,7 @@ src/
## 主包只放这些
- TabBar 页面(如首页、我的)
- TabBar 页面(工作台、功能、我的)
- 登录 / 启动过渡 / 协议等冷启动必经页
- 全局强依赖且体积很小的公共组件与工具
@@ -127,6 +127,7 @@ src/
- 有业务逻辑的函数必须写中文注释(做什么、为什么)
- 样式优先 `scoped`;尺寸用 rpx颜色尽量走主题变量`theme.json` / wot CSS 变量),避免散落魔法色值
- 列表分页、表单校验、空态/加载态要完整,不要只做「能点开」的半成品
- **下拉刷新(强制)**:有数据列表的页面必须支持下拉刷新;`pages.json` 开 `enablePullDownRefresh`,逻辑用 `usePullRefresh(reload)`(见 `src/composables/usePullRefresh.ts`);内层 `scroll-view`/`swiper` 页改用 `refresher-enabled`
# 命名规范
@@ -145,3 +146,75 @@ src/
- 禁止在业务页复制粘贴请求头、baseURL、token 逻辑
- 禁止抽屉不用 `page-container`、或用 `v-show` 控制抽屉
- 禁止提交无中文注释的复杂业务逻辑
- 禁止把 PC 后台表格/工具栏交互原样搬到小程序
- 禁止业务页自写一套分类 Tab / 顶部筛选(必须用通用组件)
# 三端兼容(强制)
业务代码与代码生成产物必须同时可运行于:
- **微信小程序mp-weixin**
- **Appapp-plus**
- **H5**
约定:
- 优先使用 `uni.*` 跨端 API仅在确有差异处使用 `#ifdef MP-WEIXIN / APP-PLUS / H5`,并写中文注释说明原因
- 禁止依赖仅某一端可用的 DOM / BOM / 私有组件而不做兜底
- TabBar、分包、`page-container` 等能力按三端都能接受的方式实现
# C 端 UI 风格(强制)
能力可以是超管,**视觉与交互必须按 C 端消费级 App**,禁止做成缩小版 PC 后台:
- 信息架构:卡片流 / 分组列表 / 宫格入口;一行一事;禁止宽表、多列表头
- 触控:点击区域 ≥ 44px约 88rpx主操作底部大按钮或悬浮主按钮
- 视觉:大圆角、留白、主题色走 `theme.json` / `uni.scss` 中的 `$nl-*`(详见 `Uniapp-Scss.mdc`
- 反馈空态、Toast危险操作再用确认框
- 登录 / 我的:品牌区 + 表单;头像 Hero + 单元格菜单
- **列表 CRUD 交互(强制)**:列表点击 → **详情页** → 底栏「编辑」→ **全屏表单页**;新增也走全屏表单
- **禁止**列表点开底部抽屉做主 CRUD`AppFormDrawer` 仅轻量选择器可用)
- 系统配置:设置卡 / 入口列表,敏感信息脱敏
- 详情页结构:`AppDetailHero` + `AppInfoGroup` + `AppBottomBar`
- 表单页结构:`AppPageForm`(分组字段 + 底栏提交)
# TabBar 信息架构(强制)
主包 Tab 固定为三页:
| Tab | 路径 | 职责 |
|-----|------|------|
| 工作台 | `pages/workbench/index` | 概览、快捷入口、最近使用 |
| 功能 | `pages/feature/index` | 应用中心:分类浏览 + 筛选 + 入口列表 |
| 我的 | `pages/mine/mine` | 个人中心 |
登录页不进 TabBar。业务深度页进 `pages-sub/*`。
## 功能页约定
- 分类 Tab 默认 **全部**;支持点击与 **左右滑动swiper** 切换
- 分类选中态本地记忆(如 `nl_feature_tab_key`
- 筛选项在分类 Tab **下方顶部**,默认收起摘要行,支持展开/收起;筛选条件可本地记忆
- 入口数据来自 `src/config/features.ts` 注册表;新增能力必须登记
# 通用组件优先(禁止重复造轮子)
优先复用 `src/components/`
- `AppPageAtmosphere`:页面氛围底(色斑渐变),毛玻璃卡片必须叠在此上才有「透」感
- `AppGlassCard`**统一毛玻璃卡片容器**variant / pressable / title禁止业务页自写半透明卡片
- `AppCategoryTabs`:分类 Tab + 可选本地记忆
- `AppFilterBar`:顶部可展开筛选壳
- `AppSearchBar` / `AppFeatureList` / `AppCardList` / `AppEmpty` / `AppSkeleton` / `AppLoading`
- `AppDetailHero` / `AppInfoGroup` / `AppBottomBar` / `AppPageForm`:详情与全屏表单
- `AppFormDrawer`非主路径仅轻量选择器CRUD 禁止用
- `AppShortcutGrid`:工作台快捷宫格
- `AppTabBar`:悬浮自定义 TabBar`wd-tabbar` shape=round
新需求先扩展上述组件,禁止业务页复制一套 Tab/筛选/抽屉/毛玻璃卡片。
# 代码生成产物落点
- 粘贴目录:`src/pages-sub/my-gen/<dash-case>/`
- 同步改 `pages.json` 的 `my-gen` 分包,并在 `src/config/features.ts` 登记(`category: 'gen'`
- 生成模板必须遵守本文件的三端兼容、C 端 UI、通用组件约定

View File

@@ -0,0 +1,95 @@
---
description: uni-app Sass/样式变量踩坑与正确写法uni.scss 禁止 @import、禁止 additionalData
alwaysApply: true
---
# Uniapp Sass / 样式变量规范
## 共享 SCSS 变量的正确方式(强制)
uni-appVue3 + Vite会把 [`src/uni.scss`](src/uni.scss) **全文注入**到每个页面/组件的 `<style lang="scss">` 前面。
因此 **`$nl-*` 与 `$uni-*` 必须直接写在 `uni.scss` 里**(变量定义内联在该文件)。
业务组件 / 页面的 scoped 样式:
- 可以直接使用 `$nl-color-primary` 等变量
- **禁止**再 `@import` tokens / `@/` 路径
全局页面壳样式可放在 `src/styles/app.scss`,由 `App.vue` 用相对路径引入;依赖 uni 已注入的 `$nl-*`。
### 正确示例
```scss
/* src/uni.scss —— 直接写变量,不要 @import */
$nl-color-primary: #ff6b00;
$uni-color-primary: $nl-color-primary;
```
```scss
/* 任意组件 scoped */
.btn {
color: $nl-color-primary;
}
```
## 已踩坑:`uni.scss` 里 `@import './styles/tokens.scss'` 会失败
### 现象
```text
[sass] Can't find stylesheet to import.
@import './styles/tokens.scss';
src\pages\workbench\index.vue 9:9
src\components\AppShortcutGrid.vue 9:9
```
### 原因
`uni.scss` 不是「先编译再引用结果」,而是把**源码拼进**当前正在编译的 Vue 文件样式里。
于是 `@import './styles/tokens.scss'` 会相对 **workbench/index.vue / AppShortcutGrid.vue** 去找 `./styles/`,而不是相对 `src/uni.scss`。
### 结论(强制)
- **`uni.scss` 内禁止 `@import` 任何其它 scss**(含 `./styles/tokens.scss`
- 主题 token 变更只改 `src/uni.scss`
- `src/styles/tokens.scss` 仅占位,禁止再被引用
## 已踩坑:不要用 vite `scss.additionalData` 注入 token
### 现象 1函数被塞进样式表
`additionalData` 传 **函数** 时,在 `@dcloudio/vite-plugin-uni` 下可能被 `toString()` 拼进 SCSS
- `expected ... const normalized filename.replace...`
**禁止 `additionalData` 使用函数。**
### 现象 2`$nl-*` Undefined variable
字符串形式的 `additionalData` 与 `uni.scss` 注入顺序不稳定,仍可能:
```text
$uni-color-primary: $nl-color-primary; ← Undefined variable
```
**禁止用 `additionalData` 作为 `$nl-*` 的定义源。**
## 已踩坑:组件内 `@import '@/styles/xxx.scss'`
Sass 会把 `@/styles/...` 的 `@` 当成 at-rule路径变成 `./styles/...`,报找不到文件。
**SCSS 里禁止 `@/` 别名。** 共享变量只走 `uni.scss`。
## vite.config.ts 约定
- 保留 `@` → `src` 的 **JS/TS** 别名
- **不要**配置 `css.preprocessorOptions.scss.additionalData` 注入业务 token
- **不要**指望 `includePaths` + `uni.scss` 内 `@import` 一劳永逸(注入后解析基准是组件路径)
## 排错清单
1. 报 `@import './styles/tokens.scss'` 找不到(定位在某个 page/component→ 检查 `uni.scss` 是否还有 `@import`,删掉并改为内联变量,重启 dev
2. 报 `$nl-*` / `$uni-*` Undefined → 确认变量写在 `uni.scss` 且无错误的 import 打断编译
3. 报 `const normalized` 等 JS 残片 → 删除错误的 `additionalData` 函数配置
4. 报 `@import ./styles/tokens`(组件自身)→ 删掉组件里的 `@/styles/...` import

2
.env.development Normal file
View File

@@ -0,0 +1,2 @@
# 需带 /api 前缀(与 Laravel api 路由一致);按实际后端地址修改
VITE_API_BASE_URL=http://127.0.0.1:18006/api

1
.env.production Normal file
View File

@@ -0,0 +1 @@
VITE_API_BASE_URL=https://your-api.example.com/api

27
components.d.ts vendored
View File

@@ -7,10 +7,27 @@ export {}
declare module 'vue' {
export interface GlobalComponents {
AppFooter: typeof import('./src/components/AppFooter.vue')['default']
AppLogos: typeof import('./src/components/AppLogos.vue')['default']
InputEntry: typeof import('./src/components/InputEntry.vue')['default']
WdButton: typeof import('wot-design-uni/components/wd-button/wd-button.vue')['default']
WdPopup: typeof import('wot-design-uni/components/wd-popup/wd-popup.vue')['default']
AppBottomBar: typeof import('./src/components/AppBottomBar.vue')['default']
AppCardList: typeof import('./src/components/AppCardList.vue')['default']
AppCategoryTabs: typeof import('./src/components/AppCategoryTabs.vue')['default']
AppDetailHero: typeof import('./src/components/AppDetailHero.vue')['default']
AppEmpty: typeof import('./src/components/AppEmpty.vue')['default']
AppFab: typeof import('./src/components/AppFab.vue')['default']
AppFeatureList: typeof import('./src/components/AppFeatureList.vue')['default']
AppFilterBar: typeof import('./src/components/AppFilterBar.vue')['default']
AppFormDrawer: typeof import('./src/components/AppFormDrawer.vue')['default']
AppGlassCard: typeof import('./src/components/AppGlassCard.vue')['default']
AppInfoGroup: typeof import('./src/components/AppInfoGroup.vue')['default']
AppLoading: typeof import('./src/components/AppLoading.vue')['default']
AppPageAtmosphere: typeof import('./src/components/AppPageAtmosphere.vue')['default']
AppPageForm: typeof import('./src/components/AppPageForm.vue')['default']
AppSearchBar: typeof import('./src/components/AppSearchBar.vue')['default']
AppShortcutGrid: typeof import('./src/components/AppShortcutGrid.vue')['default']
AppSkeleton: typeof import('./src/components/AppSkeleton.vue')['default']
AppTabBar: typeof import('./src/components/AppTabBar.vue')['default']
WdActionSheet: typeof import('wot-design-uni/components/wd-action-sheet/wd-action-sheet.vue')['default']
WdConfigProvider: typeof import('wot-design-uni/components/wd-config-provider/wd-config-provider.vue')['default']
WdIcon: typeof import('wot-design-uni/components/wd-icon/wd-icon.vue')['default']
WdLoading: typeof import('wot-design-uni/components/wd-loading/wd-loading.vue')['default']
}
}

View File

@@ -2,7 +2,7 @@
<html>
<head>
<meta charset="UTF-8" />
<link rel="icon" href="static/logo.svg">
<link rel="icon" href="static/logo.png">
<script>
const coverSupport = 'CSS' in window && typeof CSS.supports === 'function' && (CSS.supports('top: env(a)')
|| CSS.supports('top: constant(a)'))

View File

@@ -61,6 +61,7 @@
"@dcloudio/uni-mp-weixin": "3.0.0-4070620250821001",
"@dcloudio/uni-mp-xhs": "3.0.0-4070620250821001",
"@dcloudio/uni-quickapp-webview": "3.0.0-4070620250821001",
"markdown-it": "^15.0.0",
"pinia": "2.2.4",
"vue": "3.4.21",
"vue-i18n": "9.6.2",
@@ -74,6 +75,7 @@
"@dcloudio/uni-stacktracey": "3.0.0-4070620250821001",
"@dcloudio/vite-plugin-uni": "3.0.0-4070620250821001",
"@mini-types/alipay": "^3.0.14",
"@types/markdown-it": "^14.1.2",
"@types/node": "^24.5.2",
"@uni-helper/eslint-config": "^0.5.0",
"@uni-helper/plugin-uni": "0.1.0",

1814
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,3 +2,6 @@ allowBuilds:
core-js: true
esbuild: true
vue-demi: true
# pnpm v11 起 .npmrc 的 shamefully-hoist 不再生效,必须写在这里;
# uni-app 的 vite 构建按根 node_modules 平铺解析依赖markdown-it 的 mdurl/entities 等),不平铺会构建失败
shamefullyHoist: true

View File

@@ -1,7 +1,21 @@
<script setup lang="ts">
/**
* 应用入口:启动时恢复登录态与主题偏好,未登录则引导登录页
*/
import { onLaunch } from '@dcloudio/uni-app'
onLaunch(() => {})
import { useThemeStore } from '@/stores/theme'
import { useUserStore } from '@/stores/user'
onLaunch(() => {
const userStore = useUserStore()
userStore.hydrateFromStorage()
// 主题:恢复深色模式偏好并监听系统主题变化
const themeStore = useThemeStore()
themeStore.init()
})
</script>
<style></style>
<style lang="scss">
@import './styles/app.scss';
</style>

26
src/api/admin.ts Normal file
View File

@@ -0,0 +1,26 @@
/**
* 管理员 CRUD API
*/
import { get, post } from '@/utils/request'
const prefix = 'admin/'
export function getAdminList(params: Record<string, any> = {}) {
return get<any>(`${prefix}list`, params)
}
export function getAdminInfo(id: number) {
return get<any>(`${prefix}detail`, { id })
}
export function createAdmin(data: Record<string, any>) {
return post<any>(`${prefix}create`, data)
}
export function updateAdmin(data: Record<string, any>) {
return post<any>(`${prefix}update`, data)
}
export function deleteAdmin(data: Record<string, any>) {
return post<any>(`${prefix}delete`, data)
}

34
src/api/auth.ts Normal file
View File

@@ -0,0 +1,34 @@
/**
* 认证相关 API
*/
import { get, post } from '@/utils/request'
/** 账号密码登录 */
export function loginApi(data: { account: string; password: string }) {
return post<{ token: string }>('login', data, { skipAuth: true, silentAuth: true })
}
/** 当前登录用户信息 */
export function getUserInfoApi() {
return get<any>('admin/my-info')
}
/** 更新个人资料 */
export function updateProfileApi(data: Record<string, any>) {
return post<any>('admin/update-profile', data)
}
/** 修改密码 */
export function changePasswordApi(data: Record<string, any>) {
return post<any>('admin/change-password', data)
}
/** 我的登录日志 */
export function getLoginLogApi(params: Record<string, any> = {}) {
return get<any>('admin/login-log', params)
}
/** 我的操作日志 */
export function getOpLogApi(params: Record<string, any> = {}) {
return get<any>('admin/op-log', params)
}

63
src/api/media.ts Normal file
View File

@@ -0,0 +1,63 @@
/**
* 新媒体运营(公众号图文)相关请求
* 移动端只做:列表 / 预览 / 一键发布 / 版本回退,不做排版编辑(完整排版在 PC 编辑器)
*/
import { get, post } from '@/utils/request'
/** 图文分页列表(后端不返回 content_md 大字段) */
export function getWechatArticleList(params: Record<string, any> = {}) {
return get<any>('wechat-article/list', params)
}
/** 图文详情(含 content_md预览页渲染用 */
export function getWechatArticleInfo(id: number) {
return get<any>('wechat-article/detail', { id })
}
/** 新建图文(后端写「创建」版本流水,排版主题在 PC 编辑器另配) */
export function createWechatArticle(data: Record<string, any>) {
return post<any>('wechat-article/create', data)
}
/** 更新图文后端写「修改」版本流水version_no+1 */
export function updateWechatArticle(data: Record<string, any>) {
return post<any>('wechat-article/update', data)
}
/** 删除图文(后端写「删除」版本流水留痕) */
export function deleteWechatArticle(ids: number[]) {
return post<any>('wechat-article/delete', { ids })
}
/** 某篇文章的版本流水(倒序) */
export function getWechatArticleVersionList(params: Record<string, any> = {}) {
return get<any>('wechat-article/version-list', params)
}
/** 版本快照详情(含该版本 content_md */
export function getWechatArticleVersionInfo(id: number) {
return get<any>('wechat-article/version-detail', { id })
}
/** 回退到指定版本快照覆盖回文章version_no+1 */
export function rollbackWechatArticle(data: { id: number, version_id: number }) {
return post<any>('wechat-article/rollback', data)
}
/**
* 一键发布content_html 为渲染好的内联样式 HTML
* 移动端用简化排版渲染(详见 pages-sub/media/utils/markdown.ts完整主题排版请在 PC 发布
*/
export function publishWechatArticle(data: { id: number, content_html: string, account_id?: number }) {
return post<any>('wechat-article/publish', data)
}
/** 查询发布审核状态freepublish 异步审核,需主动刷新) */
export function getWechatArticlePublishStatus(id: number) {
return get<any>('wechat-article/publish-status', { id })
}
/** 公众号账号下拉(发布时选账号,默认账号排最前) */
export function getWechatAccountOption() {
return get<any>('wechat-account/option')
}

26
src/api/menu.ts Normal file
View File

@@ -0,0 +1,26 @@
/**
* 菜单 CRUD API
*/
import { get, post } from '@/utils/request'
const prefix = 'menu/'
export function getMenuList(params: Record<string, any> = {}) {
return get<any>(`${prefix}list`, params)
}
export function getMenuInfo(id: number) {
return get<any>(`${prefix}detail`, { id })
}
export function createMenu(data: Record<string, any>) {
return post<any>(`${prefix}create`, data)
}
export function updateMenu(data: Record<string, any>) {
return post<any>(`${prefix}update`, data)
}
export function deleteMenu(data: Record<string, any>) {
return post<any>(`${prefix}delete`, data)
}

30
src/api/role.ts Normal file
View File

@@ -0,0 +1,30 @@
/**
* 角色 CRUD API
*/
import { get, post } from '@/utils/request'
const prefix = 'role/'
export function getRoleList(params: Record<string, any> = {}) {
return get<any>(`${prefix}list`, params)
}
export function getRoleOption(params: Record<string, any> = {}) {
return get<any>(`${prefix}option`, params)
}
export function getRoleInfo(id: number) {
return get<any>(`${prefix}detail`, { id })
}
export function createRole(data: Record<string, any>) {
return post<any>(`${prefix}create`, data)
}
export function updateRole(data: Record<string, any>) {
return post<any>(`${prefix}update`, data)
}
export function deleteRole(data: Record<string, any>) {
return post<any>(`${prefix}delete`, data)
}

75
src/api/system.ts Normal file
View File

@@ -0,0 +1,75 @@
/**
* 系统配置相关AI / OSS / 接口注册表
*/
import { get, post } from '@/utils/request'
/** AI 运行时选项 */
export function getAiRuntimeOptions() {
return get<any>('ai-config/runtime-options')
}
export function saveAiRuntime(data: Record<string, any>) {
return post<any>('ai-config/save-runtime', data)
}
export function getAiKeyList(params: Record<string, any> = {}) {
return get<any>('ai-config/key-list', params)
}
export function getAiPlatformOption() {
return get<any>('ai-config/platform-option')
}
export function createAiKey(data: Record<string, any>) {
return post<any>('ai-config/create-key', data)
}
export function updateAiKey(data: Record<string, any>) {
return post<any>('ai-config/update-key', data)
}
export function deleteAiKey(ids: number[]) {
return post<any>('ai-config/delete-key', { ids })
}
export function getAiGenerationList(params: Record<string, any> = {}) {
return get<any>('ai-config/generation-list', params)
}
/** OSS */
export function getOssDriverOptions() {
return get<any>('oss-config/driver-options')
}
export function getOssRuntimeOptions() {
return get<any>('oss-config/runtime-options')
}
export function saveOssRuntime(data: { id: number }) {
return post<any>('oss-config/save-runtime', data)
}
export function getOssConfigList(params: Record<string, any> = {}) {
return get<any>('oss-config/list', params)
}
export function createOssConfig(data: Record<string, any>) {
return post<any>('oss-config/create', data)
}
export function updateOssConfig(data: Record<string, any>) {
return post<any>('oss-config/update', data)
}
export function deleteOssConfig(ids: number[]) {
return post<any>('oss-config/delete', { ids })
}
/** 接口注册表 */
export function getApiEndpointList(params: Record<string, any> = {}) {
return get<any>('api-endpoint/list', params)
}
export function updateApiEndpoint(data: Record<string, any>) {
return post<any>('api-endpoint/update', data)
}

9
src/api/upload.ts Normal file
View File

@@ -0,0 +1,9 @@
/**
* 上传 API
*/
import { uploadFile } from '@/utils/request'
/** 上传图片,返回带 url 的结果 */
export function uploadImage(filePath: string) {
return uploadFile<any>('upload/image', filePath, 'file')
}

View File

@@ -0,0 +1,96 @@
<script setup lang="ts">
/**
* 详情/表单底栏:主按钮 + 可选次按钮,含安全区
* C 端主操作固定在底部,不用抽屉确认
*/
withDefaults(
defineProps<{
primaryText?: string
secondaryText?: string
dangerText?: string
loading?: boolean
disabled?: boolean
}>(),
{
primaryText: '编辑',
secondaryText: '',
dangerText: '',
loading: false,
disabled: false,
},
)
const emit = defineEmits<{
(e: 'primary'): void
(e: 'secondary'): void
(e: 'danger'): void
}>()
</script>
<template>
<view class="bar-space" />
<view class="bar">
<view v-if="dangerText" class="bar__btn is-danger" @tap="emit('danger')">{{ dangerText }}</view>
<view v-if="secondaryText" class="bar__btn is-ghost" @tap="emit('secondary')">{{ secondaryText }}</view>
<view
class="bar__btn is-primary"
:class="{ 'is-disabled': loading || disabled }"
@tap="!(loading || disabled) && emit('primary')"
>
{{ loading ? '提交中…' : primaryText }}
</view>
</view>
</template>
<style scoped lang="scss">
.bar-space {
height: calc(128rpx + env(safe-area-inset-bottom));
}
.bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 20;
display: flex;
gap: 16rpx;
padding: 16rpx 28rpx calc(16rpx + env(safe-area-inset-bottom));
/* 底栏背景与分割线走主题变量,暗色自动切换 */
background: $nl-color-bar-bg;
border-top: 1rpx solid $nl-color-divider;
backdrop-filter: blur(20px) saturate(1.3);
-webkit-backdrop-filter: blur(20px) saturate(1.3);
}
.bar__btn {
flex: 1;
height: 88rpx;
border-radius: 999rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 30rpx;
font-weight: 600;
}
.bar__btn.is-primary {
background: $nl-color-primary;
color: #fff;
}
.bar__btn.is-ghost {
background: $nl-color-bg-page;
color: $nl-color-text-secondary;
}
.bar__btn.is-danger {
background: $nl-color-danger-soft;
color: $nl-color-danger-deep;
flex: 0.7;
}
.bar__btn.is-disabled {
opacity: 0.65;
}
</style>

View File

@@ -0,0 +1,168 @@
<script setup lang="ts">
/**
* C 端业务卡片列表:毛玻璃卡 + 主题色块 + 按压进详情
*/
import AppGlassCard from '@/components/AppGlassCard.vue'
export interface AppCardItem {
id?: string | number
title: string
subtitle?: string
/** 左侧短图标文字,缺省取标题首字 */
iconText?: string
/** 可选头像图 */
avatar?: string
statusText?: string
statusType?: 'default' | 'success' | 'danger' | 'warning'
[key: string]: any
}
defineProps<{
list: AppCardItem[]
}>()
const emit = defineEmits<{
(e: 'select', item: AppCardItem): void
}>()
function iconOf(item: AppCardItem) {
if (item.iconText) return item.iconText
const t = String(item.title || '')
return t.slice(0, 1) || '·'
}
</script>
<template>
<view class="card-list">
<AppGlassCard
v-for="(item, index) in list"
:key="item.id ?? index"
pressable
no-padding
class="card-list__wrap"
@tap="emit('select', item)"
>
<view class="card-list__item">
<image
v-if="item.avatar"
class="card-list__avatar"
:src="item.avatar"
mode="aspectFill"
/>
<view v-else class="card-list__icon">{{ iconOf(item) }}</view>
<view class="card-list__main">
<text class="card-list__title">{{ item.title }}</text>
<text v-if="item.subtitle" class="card-list__sub">{{ item.subtitle }}</text>
<slot name="extra" :item="item" />
</view>
<view class="card-list__right">
<text
v-if="item.statusText"
class="card-list__status"
:class="`is-${item.statusType || 'default'}`"
>
{{ item.statusText }}
</text>
<!-- 箭头色走 CSS 变量wd-icon color 会内联到 stylevar 运行时可解析暗色自动切换 -->
<wd-icon name="arrow-right" size="28rpx" color="var(--nl-color-text-muted, #6b7280)" />
</view>
</view>
</AppGlassCard>
</view>
</template>
<style scoped lang="scss">
.card-list {
display: flex;
flex-direction: column;
gap: 24rpx;
padding: 8rpx 28rpx 32rpx;
}
.card-list__item {
display: flex;
align-items: center;
padding: 28rpx 24rpx;
min-height: 128rpx;
}
.card-list__avatar,
.card-list__icon {
width: 96rpx;
height: 96rpx;
border-radius: 28rpx;
margin-right: 22rpx;
flex-shrink: 0;
}
.card-list__icon {
/* 实橙渐变 + 白字:作为卡片视觉锚点,替换发虚的淡橙底橙字 */
background: $nl-gradient-primary;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 36rpx;
font-weight: 700;
box-shadow: 0 8rpx 20rpx rgba(255, 107, 0, 0.25);
}
.card-list__main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 8rpx;
}
.card-list__title {
font-size: 32rpx;
font-weight: 700;
color: $nl-color-text;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-list__sub {
font-size: 24rpx;
/* 副标题用 secondarymuted 在 24rpx 小字下对比度不够) */
color: $nl-color-text-secondary;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-list__right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8rpx;
margin-left: 12rpx;
flex-shrink: 0;
}
.card-list__status {
font-size: 22rpx;
padding: 6rpx 14rpx;
border-radius: 999rpx;
/* 默认态改可见中性底(原半透明白在玻璃卡上基本不可见) */
background: $nl-color-fill-soft;
color: $nl-color-text-secondary;
}
.card-list__status.is-success {
background: $nl-color-success-soft;
color: $nl-color-success-deep;
}
.card-list__status.is-danger {
background: $nl-color-danger-soft;
color: $nl-color-danger-deep;
}
.card-list__status.is-warning {
background: $nl-color-warning-soft;
color: $nl-color-warning-deep;
}
</style>

View File

@@ -0,0 +1,106 @@
<script setup lang="ts">
/**
* 顶部分类 Tab与外部 swiper 通过 v-model 同步,可选本地记忆当前分类
*/
import { onMounted, ref, watch } from 'vue'
export interface CategoryTabItem {
key: string
label: string
}
const props = withDefaults(
defineProps<{
modelValue: string
items: CategoryTabItem[]
/** 传入则自动读写本地记忆 */
storageKey?: string
}>(),
{ storageKey: '' },
)
const emit = defineEmits<{
(e: 'update:modelValue', v: string): void
(e: 'change', v: string): void
}>()
const inner = ref(props.modelValue)
onMounted(() => {
if (!props.storageKey) return
const saved = uni.getStorageSync(props.storageKey)
if (saved && props.items.some((i) => i.key === saved)) {
inner.value = String(saved)
emit('update:modelValue', inner.value)
emit('change', inner.value)
}
})
watch(
() => props.modelValue,
(v) => {
if (v !== inner.value) inner.value = v
},
)
/** 点击切换分类并记忆 */
function onTap(key: string) {
if (key === inner.value) return
inner.value = key
emit('update:modelValue', key)
emit('change', key)
if (props.storageKey) {
uni.setStorageSync(props.storageKey, key)
}
}
</script>
<template>
<scroll-view class="cat-tabs" scroll-x :show-scrollbar="false">
<view class="cat-tabs__inner">
<view
v-for="item in items"
:key="item.key"
class="cat-tabs__item"
:class="{ 'is-active': item.key === inner }"
@tap="onTap(item.key)"
>
<text class="cat-tabs__label">{{ item.label }}</text>
</view>
</view>
</scroll-view>
</template>
<style scoped lang="scss">
.cat-tabs {
width: 100%;
white-space: nowrap;
}
.cat-tabs__inner {
display: inline-flex;
padding: 8rpx 24rpx 16rpx;
gap: 12rpx;
}
.cat-tabs__item {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: $nl-touch-min;
padding: 0 28rpx;
border-radius: 999rpx;
background: $nl-color-bg-card;
color: $nl-color-text-secondary;
}
.cat-tabs__item.is-active {
background: $nl-color-primary-soft;
color: $nl-color-primary;
font-weight: 600;
}
.cat-tabs__label {
font-size: 28rpx;
}
</style>

View File

@@ -0,0 +1,114 @@
<script setup lang="ts">
/**
* 详情页 Hero毛玻璃资料卡顶区
*/
import AppGlassCard from '@/components/AppGlassCard.vue'
withDefaults(
defineProps<{
title: string
subtitle?: string
statusText?: string
statusType?: 'default' | 'success' | 'danger' | 'warning'
avatar?: string
iconText?: string
}>(),
{
subtitle: '',
statusText: '',
statusType: 'default',
avatar: '',
iconText: '',
},
)
</script>
<template>
<view class="hero-wrap">
<AppGlassCard variant="tint" radius="36rpx" padding="48rpx 32rpx 40rpx">
<view class="hero">
<image v-if="avatar" class="hero__avatar" :src="avatar" mode="aspectFill" />
<view v-else class="hero__icon">{{ iconText || title.slice(0, 1) || '·' }}</view>
<text class="hero__title">{{ title }}</text>
<text v-if="subtitle" class="hero__sub">{{ subtitle }}</text>
<text
v-if="statusText"
class="hero__status"
:class="`is-${statusType}`"
>
{{ statusText }}
</text>
</view>
</AppGlassCard>
</view>
</template>
<style scoped lang="scss">
.hero-wrap {
/* 顶部间距由全局 .nl-page 统一提供24rpx这里不再叠加 */
margin: 0 28rpx 24rpx;
}
.hero {
display: flex;
flex-direction: column;
align-items: center;
}
.hero__avatar,
.hero__icon {
width: 148rpx;
height: 148rpx;
border-radius: 50%;
margin-bottom: 24rpx;
}
.hero__icon {
/* 实橙渐变 + 白字:与列表图标块同口径,避免淡橙底发虚 */
background: $nl-gradient-primary;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 56rpx;
font-weight: 800;
box-shadow: 0 12rpx 32rpx rgba(255, 107, 0, 0.28);
}
.hero__title {
font-size: 40rpx;
font-weight: 800;
color: $nl-color-text;
}
.hero__sub {
margin-top: 10rpx;
font-size: 26rpx;
color: $nl-color-text-secondary;
}
.hero__status {
margin-top: 20rpx;
font-size: 22rpx;
padding: 8rpx 20rpx;
border-radius: 999rpx;
/* 默认态改可见中性底,与列表状态 pill 同口径 */
background: $nl-color-fill-soft;
color: $nl-color-text-secondary;
}
.hero__status.is-success {
background: $nl-color-success-soft;
color: $nl-color-success-deep;
}
.hero__status.is-danger {
background: $nl-color-danger-soft;
color: $nl-color-danger-deep;
}
.hero__status.is-warning {
background: $nl-color-warning-soft;
color: $nl-color-warning-deep;
}
</style>

View File

@@ -0,0 +1,51 @@
<script setup lang="ts">
/**
* 统一空态:毛玻璃圆标 + 文案
*/
withDefaults(
defineProps<{
text?: string
icon?: string
}>(),
{ text: '暂无内容', icon: 'view-list' },
)
</script>
<template>
<view class="empty">
<view class="empty__icon">
<wd-icon :name="icon" size="64rpx" color="#ff6b00" />
</view>
<text class="empty__text">{{ text }}</text>
</view>
</template>
<style scoped lang="scss">
.empty {
padding: 96rpx 32rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.empty__icon {
width: 140rpx;
height: 140rpx;
border-radius: 50%;
background: $nl-glass-bg;
border: 1rpx solid $nl-glass-border;
box-shadow: $nl-glass-shadow;
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 28rpx;
}
.empty__text {
font-size: 26rpx;
color: $nl-color-text-muted;
}
</style>

63
src/components/AppFab.vue Normal file
View File

@@ -0,0 +1,63 @@
<script setup lang="ts">
/**
* 悬浮主操作按钮FAB列表页「新增」等主操作统一入口
* 为什么封装:此前 admin/role/menu 列表页与代码生成模板各自复制了一份
* fixed 定位 + 阴影 + 按压态样式,违反「禁止重复造轮子」;收敛到这里统一维护
*/
withDefaults(
defineProps<{
/** 按钮文案 */
text?: string
/** wot 图标名,传空串则不渲染图标 */
icon?: string
}>(),
{
text: '新增',
icon: 'add',
},
)
const emit = defineEmits<{
(e: 'tap'): void
}>()
</script>
<template>
<view class="fab" hover-class="fab--press" :hover-stay-time="80" @tap="emit('tap')">
<wd-icon v-if="icon" :name="icon" size="36rpx" color="#ffffff" />
<text class="fab__text">{{ text }}</text>
</view>
</template>
<style scoped lang="scss">
.fab {
position: fixed;
right: 32rpx;
/* 预留底部安全区,避免全面屏手势条遮挡 */
bottom: calc(48rpx + env(safe-area-inset-bottom));
z-index: 30;
min-width: 120rpx;
height: 88rpx;
padding: 0 32rpx;
border-radius: 999rpx;
background: $nl-gradient-primary;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
gap: 8rpx;
font-weight: 700;
box-shadow: 0 16rpx 40rpx rgba(255, 107, 0, 0.35);
transition: transform 0.16s ease, opacity 0.16s ease;
}
.fab__text {
font-size: 28rpx;
color: #fff;
}
.fab--press {
opacity: 0.85;
transform: scale(0.96);
}
</style>

View File

@@ -0,0 +1,93 @@
<script setup lang="ts">
/**
* 功能入口列表:毛玻璃卡 + wd-icon
*/
import AppGlassCard from '@/components/AppGlassCard.vue'
import type { FeatureItem } from '@/config/features'
defineProps<{
list: FeatureItem[]
}>()
const emit = defineEmits<{
(e: 'select', item: FeatureItem): void
}>()
</script>
<template>
<view class="feature-list">
<AppGlassCard
v-for="item in list"
:key="item.key"
pressable
no-padding
@tap="emit('select', item)"
>
<view class="feature-list__item">
<view class="feature-list__icon">
<!-- 图标块改实橙渐变底图标用白色wd-icon 只收色值无法引 scss 变量 -->
<wd-icon :name="item.icon" size="44rpx" color="#ffffff" />
</view>
<view class="feature-list__body">
<text class="feature-list__title">{{ item.title }}</text>
<text class="feature-list__desc">{{ item.desc }}</text>
</view>
<!-- 箭头色走 CSS 变量暗色自动切换 -->
<wd-icon name="arrow-right" size="32rpx" color="var(--nl-color-text-muted, #6b7280)" />
</view>
</AppGlassCard>
</view>
</template>
<style scoped lang="scss">
.feature-list {
padding: 8rpx 28rpx 40rpx;
display: flex;
flex-direction: column;
gap: 24rpx;
}
.feature-list__item {
display: flex;
align-items: center;
min-height: 148rpx;
padding: 28rpx 26rpx;
}
.feature-list__icon {
width: 96rpx;
height: 96rpx;
border-radius: 28rpx;
/* 实橙渐变 + 白图标:作为行内视觉锚点,替换发虚的淡橙底 */
background: $nl-gradient-primary;
box-shadow: 0 8rpx 20rpx rgba(255, 107, 0, 0.25);
display: flex;
align-items: center;
justify-content: center;
margin-right: 22rpx;
flex-shrink: 0;
}
.feature-list__body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 8rpx;
}
.feature-list__title {
font-size: 32rpx;
font-weight: 700;
color: $nl-color-text;
}
.feature-list__desc {
font-size: 24rpx;
/* 描述文字用 secondary保证小字号对比度 */
color: $nl-color-text-secondary;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,126 @@
<script setup lang="ts">
/**
* 顶部可展开/收起筛选壳:摘要行 + 展开面板 + 重置/完成
* 具体筛选项通过默认插槽注入,避免各业务页重复造轮子
*/
import { computed, ref, watch } from 'vue'
const props = withDefaults(
defineProps<{
/** 已选条件数量,用于摘要展示 */
selectedCount?: number
/** 摘要文案前缀 */
summaryPrefix?: string
storageKey?: string
}>(),
{
selectedCount: 0,
summaryPrefix: '筛选',
storageKey: '',
},
)
const emit = defineEmits<{
(e: 'reset'): void
(e: 'confirm'): void
(e: 'expand-change', open: boolean): void
}>()
const expanded = ref(false)
const summaryText = computed(() => {
return `${props.summaryPrefix} · 已选 ${props.selectedCount}`
})
watch(expanded, (v) => emit('expand-change', v))
function toggle() {
expanded.value = !expanded.value
}
function onReset() {
emit('reset')
}
function onConfirm() {
expanded.value = false
emit('confirm')
}
</script>
<template>
<view class="filter-bar">
<view class="filter-bar__summary" @tap="toggle">
<text class="filter-bar__text">{{ summaryText }}</text>
<text class="filter-bar__arrow">{{ expanded ? '收起' : '展开' }}</text>
</view>
<view v-if="expanded" class="filter-bar__panel">
<slot />
<view class="filter-bar__actions">
<view class="filter-bar__btn is-ghost" @tap="onReset">重置</view>
<view class="filter-bar__btn is-primary" @tap="onConfirm">完成</view>
</view>
</view>
</view>
</template>
<style scoped lang="scss">
.filter-bar {
margin: 0 24rpx 16rpx;
background: $nl-color-bg-card;
border-radius: $nl-radius-lg;
overflow: hidden;
/* 与搜索框同口径的细边 + 轻影,保持顶部工具区轮廓统一 */
border: 1rpx solid $nl-glass-border;
box-shadow: 0 2rpx 8rpx rgba(15, 23, 42, 0.04);
}
.filter-bar__summary {
display: flex;
align-items: center;
justify-content: space-between;
min-height: $nl-touch-min;
padding: 0 28rpx;
}
.filter-bar__text {
font-size: 26rpx;
color: $nl-color-text;
}
.filter-bar__arrow {
font-size: 24rpx;
color: $nl-color-primary;
}
.filter-bar__panel {
padding: 8rpx 28rpx 24rpx;
border-top: 1rpx solid $nl-color-border;
}
.filter-bar__actions {
display: flex;
gap: 16rpx;
margin-top: 24rpx;
}
.filter-bar__btn {
flex: 1;
height: 72rpx;
border-radius: 999rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 28rpx;
}
.filter-bar__btn.is-ghost {
background: $nl-color-bg-page;
color: $nl-color-text-secondary;
}
.filter-bar__btn.is-primary {
background: $nl-color-primary;
color: #fff;
}
</style>

View File

@@ -1,34 +0,0 @@
<script setup lang="ts">
function handleClickGithub() {
if (window?.open) {
window.open('https://github.com/uni-helper/create-uni')
}
else {
uni.showToast({
icon: 'none',
title: '请使用浏览器打开',
})
}
}
</script>
<template>
<view class="footer" @click="handleClickGithub">
<image class="uni-helper-github__image" src="/static/github.svg" />
</view>
</template>
<style>
.footer{
position: absolute;
bottom: 1rem;
left: 50%;
transform: translateX(-50%);
color: #888;
}
.uni-helper-github__image {
display: inline-block;
height: 1em;
width: 1em;
}
</style>

View File

@@ -0,0 +1,154 @@
<script setup lang="ts">
/**
* 表单抽屉壳page-container
* 【非主路径】列表 CRUD 禁止用本组件;请走 list → detail → form 全屏页
* 仅保留给极轻量选择器(单字段 picker 等)
*/
import { nextTick, ref, watch } from 'vue'
const props = withDefaults(
defineProps<{
show: boolean
title?: string
confirmText?: string
cancelText?: string
loading?: boolean
}>(),
{
title: '编辑',
confirmText: '保存',
cancelText: '取消',
loading: false,
},
)
const emit = defineEmits<{
(e: 'update:show', v: boolean): void
(e: 'confirm'): void
(e: 'close'): void
}>()
/** 控制 page-container 是否挂载 */
const mounted = ref(false)
/** 控制 page-container 的 show用于播放关闭动画 */
const innerShow = ref(false)
watch(
() => props.show,
async (v) => {
if (v) {
mounted.value = true
await nextTick()
innerShow.value = true
return
}
innerShow.value = false
},
{ immediate: true },
)
/** 点击取消/遮罩:先关动画,由 afterleave 卸载 */
function requestClose() {
innerShow.value = false
emit('update:show', false)
}
function onAfterLeave() {
mounted.value = false
emit('close')
}
function onConfirm() {
if (props.loading) return
emit('confirm')
}
</script>
<template>
<page-container
v-if="mounted"
:show="innerShow"
position="bottom"
round
overlay
@afterleave="onAfterLeave"
@clickoverlay="requestClose"
>
<view class="drawer">
<view class="drawer__header">
<text class="drawer__title">{{ title }}</text>
<text class="drawer__close" @tap="requestClose">关闭</text>
</view>
<scroll-view class="drawer__body" scroll-y>
<slot />
</scroll-view>
<view class="drawer__footer nl-safe-bottom">
<view class="drawer__btn is-ghost" @tap="requestClose">{{ cancelText }}</view>
<view class="drawer__btn is-primary" @tap="onConfirm">
{{ loading ? '提交中' : confirmText }}
</view>
</view>
</view>
</page-container>
</template>
<style scoped lang="scss">
.drawer {
background: $nl-color-bg-card;
border-radius: 32rpx 32rpx 0 0;
max-height: 85vh;
display: flex;
flex-direction: column;
}
.drawer__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 28rpx 32rpx 16rpx;
}
.drawer__title {
font-size: 32rpx;
font-weight: 600;
}
.drawer__close {
font-size: 26rpx;
color: $nl-color-text-secondary;
padding: 12rpx;
}
.drawer__body {
max-height: 56vh;
padding: 8rpx 32rpx 24rpx;
box-sizing: border-box;
}
.drawer__footer {
display: flex;
gap: 16rpx;
padding: 16rpx 32rpx 24rpx;
border-top: 1rpx solid $nl-color-border;
}
.drawer__btn {
flex: 1;
height: 88rpx;
border-radius: 999rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 30rpx;
}
.drawer__btn.is-ghost {
background: $nl-color-bg-page;
color: $nl-color-text-secondary;
}
.drawer__btn.is-primary {
background: $nl-color-primary;
color: #fff;
}
</style>

View File

@@ -0,0 +1,136 @@
<script setup lang="ts">
/**
* 通用毛玻璃卡片C 端统一容器
* 为什么封装:业务页禁止各自写半透明/阴影;毛玻璃 + 高光描边集中在此
*/
withDefaults(
defineProps<{
/** default 常规玻璃 / strong 更实 / tint 主题暖色调 / soft 更透 */
variant?: 'default' | 'strong' | 'tint' | 'soft'
/** 内边距,传 CSS 长度;空则用默认 */
padding?: string
/** 圆角,默认 32rpx */
radius?: string
/** 是否可点击(带按压态) */
pressable?: boolean
/** 去掉默认内边距,由业务自己控 */
noPadding?: boolean
/** 可选标题(分组卡) */
title?: string
}>(),
{
variant: 'default',
padding: '',
radius: '32rpx',
pressable: false,
noPadding: false,
title: '',
},
)
const emit = defineEmits<{
(e: 'tap'): void
}>()
</script>
<template>
<view
class="glass"
:class="[
`is-${variant}`,
{
'is-pressable': pressable,
'is-no-padding': noPadding,
},
]"
:style="{
borderRadius: radius,
padding: noPadding ? '0' : padding || undefined,
}"
:hover-class="pressable ? 'glass--press' : ''"
:hover-stay-time="80"
@tap="emit('tap')"
>
<view class="glass__shine" />
<text v-if="title" class="glass__title">{{ title }}</text>
<view class="glass__body">
<slot />
</view>
</view>
</template>
<style scoped lang="scss">
.glass {
position: relative;
overflow: hidden;
padding: 28rpx 24rpx;
box-sizing: border-box;
background: $nl-glass-bg;
border: 1rpx solid $nl-glass-border;
/* inset 高光模拟玻璃上沿受光,外层走分层阴影 token */
box-shadow: inset 0 1rpx 0 $nl-glass-highlight, $nl-glass-shadow;
/* 模糊/饱和收敛14px + 1.35):底色已调实,玻璃感只做点缀,避免整卡雾蒙蒙 */
backdrop-filter: blur($nl-glass-blur) saturate(1.35);
-webkit-backdrop-filter: blur($nl-glass-blur) saturate(1.35);
}
.glass.is-strong {
background: $nl-glass-bg-strong;
}
.glass.is-soft {
background: $nl-glass-bg-soft;
box-shadow: inset 0 1rpx 0 $nl-glass-highlight, 0 8rpx 24rpx rgba(15, 23, 42, 0.05);
}
.glass.is-tint {
background: $nl-glass-bg-tint;
border-color: $nl-glass-border-soft;
box-shadow: inset 0 1rpx 0 $nl-glass-highlight, 0 2rpx 8rpx rgba(255, 107, 0, 0.05),
0 12rpx 32rpx rgba(255, 107, 0, 0.1);
}
.glass.is-no-padding {
padding: 0;
}
.glass.is-pressable {
transition: transform 0.16s ease, box-shadow 0.16s ease, opacity 0.16s ease;
}
.glass--press {
transform: scale(0.985);
opacity: 0.92;
box-shadow: inset 0 1rpx 0 $nl-glass-highlight, 0 6rpx 20rpx rgba(15, 23, 42, 0.05);
}
.glass__shine {
pointer-events: none;
position: absolute;
left: 0;
right: 0;
top: 0;
/* 只保留顶部细高光;原先 46% 高的大白渐变会洗白卡内文字对比度 */
height: 25%;
z-index: 0;
/* 高光颜色走主题变量:暗色下减弱到几乎不可见,避免灰蒙 */
background: linear-gradient(180deg, $nl-glass-shine 0%, rgba(255, 255, 255, 0) 100%);
opacity: 0.35;
}
.glass__title {
position: relative;
z-index: 1;
display: block;
margin-bottom: 12rpx;
padding: 0 4rpx;
font-size: 30rpx;
font-weight: 700;
color: $nl-color-text;
}
.glass__body {
position: relative;
z-index: 1;
}
</style>

View File

@@ -0,0 +1,80 @@
<script setup lang="ts">
/**
* 详情信息分组:毛玻璃设置项节奏(标签左 / 值右)
*/
import AppGlassCard from '@/components/AppGlassCard.vue'
export interface AppInfoRow {
label: string
value?: string | number
action?: string
}
defineProps<{
title?: string
items: AppInfoRow[]
}>()
const emit = defineEmits<{
(e: 'row-tap', item: AppInfoRow): void
}>()
</script>
<template>
<view class="info">
<text v-if="title" class="info__title">{{ title }}</text>
<AppGlassCard no-padding radius="28rpx">
<view
v-for="(item, idx) in items"
:key="`${item.label}-${idx}`"
class="info__row"
:class="{ 'is-last': idx === items.length - 1 }"
@tap="emit('row-tap', item)"
>
<text class="info__label">{{ item.label }}</text>
<text class="info__value">{{ item.value === undefined || item.value === '' ? '-' : item.value }}</text>
</view>
</AppGlassCard>
</view>
</template>
<style scoped lang="scss">
.info {
padding: 0 28rpx 24rpx;
}
.info__title {
display: block;
font-size: 26rpx;
color: $nl-color-text-muted;
margin: 0 8rpx 12rpx;
}
.info__row {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 96rpx;
gap: 24rpx;
padding: 0 28rpx;
/* 分割线走主题变量:浅色中性深、暗色中性浅 */
border-bottom: 1rpx solid $nl-color-divider;
}
.info__row.is-last {
border-bottom: none;
}
.info__label {
font-size: 28rpx;
color: $nl-color-text-secondary;
flex-shrink: 0;
}
.info__value {
font-size: 28rpx;
color: $nl-color-text;
text-align: right;
word-break: break-all;
}
</style>

View File

@@ -0,0 +1,56 @@
<script setup lang="ts">
/**
* 页面/区块 loading优先复用 wd-loading统一主题色转圈
*/
withDefaults(
defineProps<{
/** 是否展示 */
show?: boolean
/** 文案 */
text?: string
/** 全屏遮罩(列表首次加载用) */
fullscreen?: boolean
}>(),
{
show: true,
text: '加载中',
fullscreen: false,
},
)
</script>
<template>
<view
v-if="show"
class="app-loading"
:class="{ 'is-fullscreen': fullscreen }"
>
<wd-loading color="#ff6b00" size="48rpx" />
<text v-if="text" class="app-loading__text">{{ text }}</text>
</view>
</template>
<style scoped lang="scss">
.app-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16rpx;
padding: 48rpx 24rpx;
}
.app-loading.is-fullscreen {
position: fixed;
inset: 0;
z-index: 1000;
/* 遮罩底走主题变量,暗色下换深色遮罩 */
background: $nl-color-mask-bg;
padding-bottom: 120rpx;
}
.app-loading__text {
font-size: 24rpx;
color: $nl-color-text-muted;
}
</style>

View File

@@ -1,59 +0,0 @@
<script setup lang="ts">
</script>
<template>
<view class="container">
<view class="uni-helper-logo">
<image class="uni-helper-logo__image" src="/static/logo.svg" />
<text class="uni-helper-logo__label green">
uni-helper
</text>
</view>
<text class="link-bar">
+
</text>
<view class="uni-helper-logo">
<image class="uni-helper-logo__image" src="/static/vite.png" />
<text class="uni-helper-logo__label purple">
Vite
</text>
</view>
</view>
</template>
<style scoped lang="scss">
.container {
display: inline-flex;
font-size: 1.5rem;
font-weight: 300;
&:hover {
.link-bar {
transform: rotate(135deg);
}
}
}
.uni-helper-logo {
display: flex;
flex-direction: column;
align-items: center;
.uni-helper-logo__image {
display: inline-block;
height: 4.5rem;
width: 4.5rem;
}
.uni-helper-logo__label {
margin-top: -0.5rem;
}
.green {
color: #22c55e;
};
.purple {
color: #a855f7;
}
}
.link-bar {
color: #9ca3af;
margin: auto 1em;
transition: all 500ms cubic-bezier(0.4, 0, 0.2, 1);
}
</style>

View File

@@ -0,0 +1,93 @@
<script setup lang="ts">
/**
* 页面氛围底:柔和色斑 + 渐变,让毛玻璃卡片「透」出设计感
* 作为壳层放在页面最外层,业务内容用 slot禁止各页再各自画背景斑
* 同时承担主题作用域:根节点是 wd-config-providerwot 官方深色方案),
* 生成的 .wot-theme-* 类同时驱动 wot 组件配色与业务 --nl-* token
*/
import { useThemeShell } from '@/composables/useThemeShell'
withDefaults(
defineProps<{
/** warm 橙暖(默认)/ cool 中性灰 */
tone?: 'warm' | 'cool'
}>(),
{ tone: 'warm' },
)
const { wotTheme } = useThemeShell()
</script>
<template>
<wd-config-provider :theme="wotTheme">
<view class="atm" :class="`is-${tone}`">
<view class="atm__blob atm__blob--a" />
<view class="atm__blob atm__blob--b" />
<view class="atm__blob atm__blob--c" />
<view class="atm__content">
<slot />
</view>
</view>
</wd-config-provider>
</template>
<style scoped lang="scss">
.atm {
position: relative;
min-height: 100vh;
overflow: hidden;
box-sizing: border-box;
/* 整条渐变走主题变量,暗色时自动切换为深底(见 app.scss */
background: var(--nl-atm-warm, linear-gradient(165deg, #fff4ea 0%, #f3f0ec 42%, #eef1f6 100%));
}
.atm.is-cool {
background: var(--nl-atm-cool, linear-gradient(165deg, #f4f6f9 0%, #f3f0ec 50%, #e8ecf2 100%));
}
.atm__blob {
position: absolute;
border-radius: 50%;
filter: blur(2px);
pointer-events: none;
z-index: 0;
/* 暗色下色斑压暗一档,避免黑底上色斑过跳 */
opacity: var(--nl-atm-blob-opacity, 1);
}
/* 卡片提实到 0.8+ 后氛围主要从卡片边缘/间隙透出,色斑整体加强一档保住玻璃感 */
.atm__blob--a {
width: 420rpx;
height: 420rpx;
top: -80rpx;
right: -100rpx;
background: radial-gradient(circle, rgba(255, 107, 0, 0.32) 0%, rgba(255, 107, 0, 0) 70%);
}
.atm__blob--b {
width: 360rpx;
height: 360rpx;
top: 38%;
left: -140rpx;
background: radial-gradient(circle, rgba(255, 170, 80, 0.26) 0%, rgba(255, 170, 80, 0) 70%);
}
.atm__blob--c {
width: 480rpx;
height: 480rpx;
bottom: 8%;
right: -160rpx;
background: radial-gradient(circle, rgba(120, 160, 220, 0.18) 0%, rgba(120, 160, 220, 0) 70%);
}
.atm.is-cool .atm__blob--a {
background: radial-gradient(circle, rgba(100, 130, 180, 0.2) 0%, rgba(100, 130, 180, 0) 70%);
}
.atm__content {
position: relative;
z-index: 1;
min-height: 100vh;
box-sizing: border-box;
}
</style>

View File

@@ -0,0 +1,104 @@
<script setup lang="ts">
/**
* 全屏表单页布局:说明文案 + 表单插槽 + 底栏提交
* 用于新建/编辑,替代底部抽屉主路径
* 根节点是 wd-config-providerwot 官方深色方案),承担整页主题作用域
*/
import { useThemeShell } from '@/composables/useThemeShell'
withDefaults(
defineProps<{
tip?: string
submitText?: string
loading?: boolean
}>(),
{
tip: '',
submitText: '保存',
loading: false,
},
)
const emit = defineEmits<{
(e: 'submit'): void
}>()
const { wotTheme } = useThemeShell()
</script>
<template>
<wd-config-provider :theme="wotTheme">
<view class="page-form">
<text v-if="tip" class="page-form__tip">{{ tip }}</text>
<view class="page-form__card">
<slot />
</view>
<view class="bar-space" />
<view class="bar">
<view
class="bar__btn"
:class="{ 'is-disabled': loading }"
@tap="!loading && emit('submit')"
>
{{ loading ? '保存中…' : submitText }}
</view>
</view>
</view>
</wd-config-provider>
</template>
<style scoped lang="scss">
.page-form {
min-height: 100vh;
padding: 24rpx 28rpx 0;
box-sizing: border-box;
background: $nl-color-bg-page;
}
.page-form__tip {
display: block;
margin: 0 8rpx 20rpx;
font-size: 24rpx;
color: $nl-color-text-muted;
line-height: 1.5;
}
.page-form__card {
background: $nl-color-bg-card;
border-radius: 28rpx;
padding: 8rpx 28rpx;
box-shadow: 0 8rpx 28rpx rgba(15, 23, 42, 0.04);
}
.bar-space {
height: calc(128rpx + env(safe-area-inset-bottom));
}
.bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 20;
padding: 16rpx 28rpx calc(16rpx + env(safe-area-inset-bottom));
/* 走卡片底色变量,深色模式自动切换 */
background: $nl-color-bg-card;
border-top: 1rpx solid $nl-color-divider;
}
.bar__btn {
height: 88rpx;
border-radius: 999rpx;
background: $nl-color-primary;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 30rpx;
font-weight: 600;
}
.bar__btn.is-disabled {
opacity: 0.65;
}
</style>

View File

@@ -0,0 +1,107 @@
<script setup lang="ts">
/**
* 顶栏搜索C 端常见圆角搜索框
*/
import { ref, watch } from 'vue'
const props = withDefaults(
defineProps<{
modelValue?: string
placeholder?: string
}>(),
{
modelValue: '',
placeholder: '搜索',
},
)
const emit = defineEmits<{
(e: 'update:modelValue', v: string): void
(e: 'search', v: string): void
(e: 'clear'): void
}>()
const keyword = ref(props.modelValue)
watch(
() => props.modelValue,
(v) => {
if (v !== keyword.value) keyword.value = v
},
)
function onInput(e: any) {
const v = e?.detail?.value ?? ''
keyword.value = v
emit('update:modelValue', v)
}
function onConfirm() {
emit('search', keyword.value)
}
function onClear() {
keyword.value = ''
emit('update:modelValue', '')
emit('clear')
emit('search', '')
}
</script>
<template>
<view class="search-bar">
<view class="search-bar__box">
<!-- wd-icon 替代文本字符颜色走 CSS 变量暗色自动切换 -->
<wd-icon
name="search"
size="30rpx"
color="var(--nl-color-text-muted, #6b7280)"
custom-class="search-bar__icon"
/>
<input
class="search-bar__input"
type="text"
confirm-type="search"
:value="keyword"
:placeholder="placeholder"
@input="onInput"
@confirm="onConfirm"
/>
<text v-if="keyword" class="search-bar__clear" @tap="onClear">清除</text>
</view>
</view>
</template>
<style scoped lang="scss">
.search-bar {
padding: 8rpx 24rpx 8rpx;
}
.search-bar__box {
display: flex;
align-items: center;
min-height: 72rpx;
padding: 0 24rpx;
background: $nl-color-bg-card;
border-radius: 999rpx;
/* 细边 + 近距轻影:让搜索框在浅色页面上有轮廓,不再是一块平白 */
border: 1rpx solid $nl-glass-border;
box-shadow: 0 2rpx 8rpx rgba(15, 23, 42, 0.04);
}
:deep(.search-bar__icon) {
margin-right: 12rpx;
}
.search-bar__input {
flex: 1;
font-size: 28rpx;
height: 72rpx;
}
.search-bar__clear {
font-size: 24rpx;
color: $nl-color-primary;
padding-left: 16rpx;
}
</style>

View File

@@ -0,0 +1,79 @@
<script setup lang="ts">
/**
* 快捷宫格:圆角色块 + wot wd-icon按压缩小反馈
*/
export interface ShortcutItem {
key: string
title: string
icon: string
path: string
}
defineProps<{
list: ShortcutItem[]
}>()
const emit = defineEmits<{
(e: 'select', item: ShortcutItem): void
}>()
</script>
<template>
<view class="shortcut">
<view
v-for="item in list"
:key="item.key"
class="shortcut__item"
hover-class="shortcut__item--press"
:hover-stay-time="80"
@tap="emit('select', item)"
>
<view class="shortcut__icon">
<!-- 实橙渐变底 + 白图标与列表图标块统一视觉锚点语言 -->
<wd-icon :name="item.icon" size="44rpx" color="#ffffff" />
</view>
<text class="shortcut__title">{{ item.title }}</text>
</view>
</view>
</template>
<style scoped lang="scss">
.shortcut {
display: flex;
flex-wrap: wrap;
padding: 8rpx 0 0;
}
.shortcut__item {
width: 25%;
display: flex;
flex-direction: column;
align-items: center;
padding: 18rpx 8rpx 22rpx;
box-sizing: border-box;
transition: transform 0.15s ease;
}
.shortcut__item--press {
transform: scale(0.92);
opacity: 0.85;
}
.shortcut__icon {
width: 96rpx;
height: 96rpx;
border-radius: 32rpx;
background: $nl-gradient-primary;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 14rpx;
box-shadow: 0 8rpx 20rpx rgba(255, 107, 0, 0.25);
}
.shortcut__title {
font-size: 24rpx;
color: $nl-color-text;
text-align: center;
}
</style>

View File

@@ -0,0 +1,95 @@
<script setup lang="ts">
/**
* 列表骨架屏:毛玻璃占位卡 + 扫光
*/
import AppGlassCard from '@/components/AppGlassCard.vue'
withDefaults(
defineProps<{
rows?: number
}>(),
{ rows: 4 },
)
</script>
<template>
<view class="skeleton">
<AppGlassCard v-for="i in rows" :key="i" no-padding variant="soft">
<view class="skeleton__card">
<view class="skeleton__avatar skeleton__shine" />
<view class="skeleton__main">
<view class="skeleton__line skeleton__line--lg skeleton__shine" />
<view class="skeleton__line skeleton__line--sm skeleton__shine" />
</view>
</view>
</AppGlassCard>
</view>
</template>
<style scoped lang="scss">
.skeleton {
padding: 8rpx 28rpx 32rpx;
display: flex;
flex-direction: column;
/* 与 AppCardList 卡间距一致,避免骨架切换真实列表时跳动 */
gap: 24rpx;
}
.skeleton__card {
display: flex;
align-items: center;
padding: 28rpx 24rpx;
}
.skeleton__avatar {
width: 96rpx;
height: 96rpx;
border-radius: 28rpx;
margin-right: 22rpx;
flex-shrink: 0;
background: $nl-color-skeleton-bone;
}
.skeleton__main {
flex: 1;
display: flex;
flex-direction: column;
gap: 16rpx;
}
.skeleton__line {
height: 24rpx;
border-radius: 12rpx;
background: $nl-color-skeleton-bone;
}
.skeleton__line--lg {
width: 58%;
height: 28rpx;
}
.skeleton__line--sm {
width: 36%;
}
.skeleton__shine {
position: relative;
overflow: hidden;
}
.skeleton__shine::after {
content: '';
position: absolute;
inset: 0;
transform: translateX(-100%);
/* 扫光颜色走主题变量:暗色下大幅减弱 */
background: linear-gradient(90deg, transparent, $nl-color-skeleton-shine, transparent);
animation: nl-skeleton-shine 1.2s ease-in-out infinite;
}
@keyframes nl-skeleton-shine {
100% {
transform: translateX(100%);
}
}
</style>

View File

@@ -0,0 +1,238 @@
<script lang="ts">
/**
* 模块级共享状态:记录「本次切换的来源 Tab」
* 为什么需要uni-app 每个 Tab 页各挂一份 tabbarswitchTab 几乎同帧完成,
* 旧页上的展开动画会被页面切换掐掉;把来源塞进模块变量,
* 目标页的 tabbar 首帧先按来源状态渲染、下一拍再切到本页 Tab
* 过渡就能在新页上完整播放,视觉上像同一条常驻的栏在动
*/
let pendingFromTab: string | null = null
/** 取出并清空来源 Tab消费一次即失效避免陈旧值影响后续进页 */
function consumeFromTab(): string | null {
const v = pendingFromTab
pendingFromTab = null
return v
}
</script>
<script setup lang="ts">
/**
* 悬浮玻璃 TabBar自绘磨砂胶囊底 + 品牌色滑动药丸
*
* 交互形态:每项都是 图标在上、文字在下 的经典竖排(常显),选中项由药丸衬底 + 品牌色点亮。
* 丝滑关键:三等分固定槽位 + 绝对定位药丸用 translateX 滑动(纯 transform 走 GPU 合成),
* 布局零重排;此前用自适应宽度 + max-width 驱动整行重排,帧率发涩(已踩坑)
*
* 选中态与动效编排:
* - current 是展示态;点击时本页药丸先行滑动(乐观动效)并登记来源,随即 switchTab
* - 目标页 tabbar 在 setup/onShow 里消费来源:先渲染成来源状态,约 80ms 后切到
* 本页 Tab 触发过渡接力动画animating 标记防止 onShow 的静态校正打断它
* - 缓存页切回且无待播动画时onShow 强制校正回本页 modelValue杜绝高亮残留已踩坑
*/
import { computed, onMounted, ref, watch } from 'vue'
import { onShow } from '@dcloudio/uni-app'
const TAB_ITEMS = [
{ name: 'workbench', title: '工作台', icon: 'home', path: '/pages/workbench/index' },
{ name: 'feature', title: '功能', icon: 'app', path: '/pages/feature/index' },
{ name: 'mine', title: '我的', icon: 'user', path: '/pages/mine/mine' },
] as const
const props = withDefaults(
defineProps<{
/** 当前页对应的 Tab name页面静态传入如工作台传 'workbench' */
modelValue?: string
}>(),
{ modelValue: 'workbench' },
)
/** 进页动画的起始状态:首帧按来源 Tab 渲染(仅首次挂载走这里,缓存页走 onShow */
const initialFrom = consumeFromTab()
/** 展示用选中态 */
const current = ref(initialFrom && initialFrom !== props.modelValue ? initialFrom : props.modelValue)
/** 是否正在播放进页接力动画(期间 onShow 不做静态校正,避免掐断过渡) */
let animating = false
/** 从「来源状态」切到本页 Tab延迟一拍保证起始状态先完成一次绘制过渡才会触发 */
function playEnterTransition() {
animating = true
setTimeout(() => {
current.value = props.modelValue
animating = false
}, 80)
}
if (initialFrom && initialFrom !== props.modelValue) {
playEnterTransition()
}
watch(
() => props.modelValue,
(v) => {
if (v) current.value = v
},
)
/** 隐藏原生 tabBar露出自绘悬浮栏H5/App 必做;小程序 custom 后通常无原生栏) */
function hideNativeTabBar() {
try {
uni.hideTabBar({ animation: false })
} catch {
// ignore
}
}
onMounted(() => {
hideNativeTabBar()
})
// 页面每次展示:优先接力播放跨页动画;否则校正选中态(缓存页防高亮残留)
onShow(() => {
const from = consumeFromTab()
if (from && from !== props.modelValue) {
current.value = from
playEnterTransition()
} else if (!animating) {
current.value = props.modelValue
}
hideNativeTabBar()
})
/** 药丸滑动目标位(按 item 序号平移自身宽度的整数倍,纯 transform 走 GPU 合成) */
const activeIndex = computed(() => {
const i = TAB_ITEMS.findIndex((t) => t.name === current.value)
return i < 0 ? 0 : i
})
/**
* 点击切换:登记来源供目标页接力动画,本页药丸先行滑动给即时反馈,
* 同帧发起 switchTab不等动画避免拖慢跳转
*/
function onTap(item: (typeof TAB_ITEMS)[number]) {
if (item.name === current.value) return
pendingFromTab = props.modelValue
current.value = item.name
uni.switchTab({ url: item.path })
}
</script>
<template>
<!-- z-index 保持 99必须低于弹层action-sheet/popup 统一 >=120否则会压住抽屉 -->
<view class="tabbar">
<view class="tabbar__inner">
<!-- 滑动药丸绝对定位铺 1/3 槽宽translateX transform 滑动GPU 合成丝滑关键 -->
<view class="tabbar__pill" :style="`transform: translateX(${activeIndex * 100}%)`" />
<view
v-for="item in TAB_ITEMS"
:key="item.name"
class="tabbar__item"
:class="{ 'is-active': item.name === current }"
hover-class="is-pressed"
:hover-start-time="0"
:hover-stay-time="80"
@tap="onTap(item)"
>
<view class="tabbar__icon-wrap">
<wd-icon :name="item.icon" size="40rpx" />
</view>
<text class="tabbar__label">{{ item.title }}</text>
</view>
</view>
</view>
</template>
<style scoped lang="scss">
.tabbar {
position: fixed;
left: 48rpx;
right: 48rpx;
bottom: calc(20rpx + env(safe-area-inset-bottom));
z-index: 99;
}
/* 磨砂胶囊底:底面/描边走 tabbar 专属 token暗色刻意比页面底亮一档否则椭圆卡片隐形 */
.tabbar__inner {
position: relative;
display: flex;
align-items: stretch;
height: 108rpx;
padding: 12rpx;
box-sizing: border-box;
border-radius: 999rpx;
overflow: hidden;
background: $nl-tabbar-bg;
border: 1rpx solid $nl-tabbar-border;
box-shadow: inset 0 1rpx 0 $nl-glass-highlight, $nl-glass-shadow;
backdrop-filter: blur($nl-glass-blur) saturate(1.8);
-webkit-backdrop-filter: blur($nl-glass-blur) saturate(1.8);
}
/*
* 滑动药丸:宽度=内容区 1/3translateX 以自身宽度为基准滑到目标槽位
* 只动 transformGPU 合成层,不触发布局/重绘),配长尾缓出曲线,丝滑的关键
*/
.tabbar__pill {
position: absolute;
top: 12rpx;
bottom: 12rpx;
left: 12rpx;
width: calc((100% - 24rpx) / 3);
border-radius: 999rpx;
background: $nl-tabbar-pill;
transition: transform 0.45s cubic-bezier(0.3, 1.12, 0.3, 1);
will-change: transform;
pointer-events: none;
}
/* 三等分固定槽位:图标在上文字在下的经典竖排,宽度不参与动画,整行零重排 */
.tabbar__item {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4rpx;
flex: 1 1 0;
min-width: 0;
border-radius: 999rpx;
color: $nl-color-text-muted;
transition: color 0.25s ease;
z-index: 1;
}
.tabbar__item.is-active {
color: $nl-tabbar-active;
}
/* 图标微动效:激活轻微上浮放大;按压回缩,松手回弹(曲线与药丸同家族,节奏统一) */
.tabbar__icon-wrap {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: transform 0.45s cubic-bezier(0.3, 1.12, 0.3, 1);
}
.tabbar__item.is-active .tabbar__icon-wrap {
transform: translateY(-2rpx) scale(1.06);
}
.tabbar__item.is-pressed .tabbar__icon-wrap {
transform: scale(0.85);
}
/* 文字常显在图标下方:选中只变色加粗,不做揭示动画 */
.tabbar__label {
font-size: 20rpx;
line-height: 1.2;
font-weight: 500;
white-space: nowrap;
}
.tabbar__item.is-active .tabbar__label {
font-weight: 600;
}
</style>

View File

@@ -1,36 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue'
const name = ref('')
const popupShow = ref(false)
function handleClick() {
popupShow.value = true
}
</script>
<template>
<view class="input-box">
<input
v-model="name"
placeholder="What's your name?"
>
</view>
<view>
<wd-button :disabled="!name" @click="handleClick">
Hello
</wd-button>
</view>
<wd-popup v-model="popupShow" custom-style="padding: 30px 40px;">
Hello{{ ` ${name}` }} 👏
</wd-popup>
</template>
<style scoped lang="scss">
.input-box {
margin: 1rem;
padding: 0.5rem;
border-bottom: 1px solid gray;
}
</style>

View File

@@ -0,0 +1,19 @@
/**
* 页面级下拉刷新pages.json 需开 enablePullDownRefresh
* 为什么封装:统一 stopPullDownRefresh避免各页漏调导致转圈不消失
*/
import { onPullDownRefresh } from '@dcloudio/uni-app'
/**
* 注册下拉刷新:执行 refresh 后自动结束原生刷新动画
* @param refresh 刷新逻辑(通常是 reload / loadAll
*/
export function usePullRefresh(refresh: () => void | Promise<unknown>) {
onPullDownRefresh(async () => {
try {
await Promise.resolve(refresh())
} finally {
uni.stopPullDownRefresh()
}
})
}

View File

@@ -0,0 +1,23 @@
/**
* 页面壳主题组合式函数:返回 wd-config-provider 需要的 theme 值 + 进页时同步导航栏
*
* 用法(对齐 wot 官方深色方案):页面壳根节点用
* <wd-config-provider :theme="wotTheme"> 包裹整页内容;
* AppPageAtmosphere / AppPageForm 已内置,业务页无需关心
*/
import { computed } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { useThemeStore } from '@/stores/theme'
export function useThemeShell() {
const themeStore = useThemeStore()
/** 'light' | 'dark',跟随系统时已解析为具体值,首帧即正确 */
const wotTheme = computed(() => themeStore.wotTheme)
// 每次进页补齐原生导航栏/窗口背景(强制模式下 theme.json 不会帮忙)
onShow(() => themeStore.applyNavChrome())
return { themeStore, wotTheme }
}

156
src/config/features.ts Normal file
View File

@@ -0,0 +1,156 @@
/**
* 功能入口注册表:功能 Tab 分类浏览、工作台快捷入口均读此配置
* 新增分包页后在此登记,并同步 pages.json
*/
/** 功能分类:全部为虚拟分类,其余按 category 过滤 */
export type FeatureCategory = 'all' | 'system' | 'config' | 'media' | 'gen' | 'other'
export interface FeatureItem {
key: string
title: string
desc: string
/** wot-design-uni wd-icon 名称,禁止再塞 emoji/单字占位 */
icon: string
category: Exclude<FeatureCategory, 'all'>
path: string
keywords?: string[]
/** 工作台默认置顶 */
pinned?: boolean
}
export interface FeatureCategoryOption {
key: FeatureCategory
label: string
}
/** 分类 Tab 定义(默认全部) */
export const FEATURE_CATEGORIES: FeatureCategoryOption[] = [
{ key: 'all', label: '全部' },
{ key: 'system', label: '系统' },
{ key: 'config', label: '配置' },
{ key: 'media', label: '新媒体' },
{ key: 'gen', label: '生成' },
{ key: 'other', label: '其他' },
]
/** 全部功能入口 */
export const FEATURE_LIST: FeatureItem[] = [
{
key: 'admin',
title: '管理员',
desc: '账号与角色分配',
icon: 'user',
category: 'system',
path: '/pages-sub/system/pages/admin/list',
keywords: ['用户', '账号'],
pinned: true,
},
{
key: 'role',
title: '角色管理',
desc: '角色与菜单权限',
icon: 'usergroup',
category: 'system',
path: '/pages-sub/system/pages/role/list',
keywords: ['权限'],
pinned: true,
},
{
key: 'menu',
title: '菜单管理',
desc: 'PC 菜单树维护',
icon: 'view-list',
category: 'system',
path: '/pages-sub/system/pages/menu/list',
keywords: ['路由'],
},
{
key: 'system-config',
title: '系统配置',
desc: 'AI / OSS / 接口总览',
icon: 'setting',
category: 'config',
path: '/pages-sub/system/pages/config/index',
keywords: ['设置'],
pinned: true,
},
{
key: 'ai-config',
title: 'AI 管理',
desc: '模型与密钥',
icon: 'secured',
category: 'config',
path: '/pages-sub/system/pages/config/ai',
keywords: ['大模型', 'key'],
},
{
key: 'oss-config',
title: 'OSS 管理',
desc: '对象存储与启用配置',
icon: 'cloud',
category: 'config',
path: '/pages-sub/system/pages/config/oss',
keywords: ['上传', '存储'],
},
{
key: 'api-endpoint',
title: '接口管理',
desc: '操作日志开关',
icon: 'link',
category: 'config',
path: '/pages-sub/system/pages/config/api',
keywords: ['日志', '接口'],
},
{
key: 'wechat-article',
title: '图文创作',
desc: '公众号图文预览与发布',
icon: 'subscribe',
category: 'media',
path: '/pages-sub/media/pages/article/list',
keywords: ['公众号', '微信', '发布', '文章'],
pinned: true,
},
{
key: 'my-gen',
title: '代码生成模块',
desc: '粘贴生成的 uniapp 页面',
icon: 'code',
category: 'gen',
path: '/pages-sub/my-gen/pages/readme',
keywords: ['codegen', '生成'],
},
]
/** 按 key 取功能 */
export function getFeatureByKey(key: string): FeatureItem | undefined {
return FEATURE_LIST.find((f) => f.key === key)
}
/**
* 按分类 + 关键词 + 仅最近使用 过滤功能列表
*/
export function filterFeatures(options: {
category: FeatureCategory
keyword?: string
recentOnly?: boolean
recentKeys?: string[]
}): FeatureItem[] {
const kw = (options.keyword || '').trim().toLowerCase()
let list = FEATURE_LIST.slice()
if (options.category !== 'all') {
list = list.filter((f) => f.category === options.category)
}
if (options.recentOnly && options.recentKeys) {
const set = new Set(options.recentKeys)
list = list.filter((f) => set.has(f.key))
}
if (kw) {
list = list.filter((f) => {
const bag = [f.title, f.desc, ...(f.keywords || [])].join(' ').toLowerCase()
return bag.includes(kw)
})
}
return list
}

16
src/config/theme.ts Normal file
View File

@@ -0,0 +1,16 @@
/**
* 全局 wot / C 端主题色(与 uni.scss $nl-color-primary 对齐)
* 挂在 page 上,让 wd-* 默认读到 --wot-color-theme无需每页写 ConfigProvider
*/
export const NL_THEME_PRIMARY = '#ff6b00'
export const wotThemeVars = {
colorTheme: NL_THEME_PRIMARY,
colorSuccess: '#16a34a',
colorWarning: '#f59e0b',
colorDanger: '#ef4444',
buttonPrimaryBgColor: NL_THEME_PRIMARY,
buttonPrimaryColor: '#ffffff',
tabsNavLineColor: NL_THEME_PRIMARY,
tabbarActiveColor: NL_THEME_PRIMARY,
}

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
/**
* 微信小程序 custom-tab-bar 占位:高度为 0真正的悬浮栏在各 Tab 页内的 AppTabBar
* 必须保留本文件,否则 tabBar.custom=true 时微信会报错/回退原生栏
*/
function setSelected(_nameOrIndex?: string | number) {
// 选中态由页面内 AppTabBar 自行维护,这里留空满足 getTabBar().setSelected 调用
}
defineExpose({ setSelected })
</script>
<template>
<view class="tabbar-placeholder" />
</template>
<style scoped>
.tabbar-placeholder {
width: 0;
height: 0;
overflow: hidden;
}
</style>

View File

@@ -1,5 +1,5 @@
{
"name": "",
"name": "NL Admin",
"appid": "",
"description": "",
"versionName": "1.0.0",
@@ -40,7 +40,39 @@
]
},
"ios": {},
"sdkConfigs": {}
"sdkConfigs": {},
"icons": {
"android": {
"hdpi": "unpackage/res/icons/72x72.png",
"xhdpi": "unpackage/res/icons/96x96.png",
"xxhdpi": "unpackage/res/icons/144x144.png",
"xxxhdpi": "unpackage/res/icons/192x192.png"
},
"ios": {
"appstore": "unpackage/res/icons/1024x1024.png",
"ipad": {
"app": "unpackage/res/icons/76x76.png",
"app@2x": "unpackage/res/icons/152x152.png",
"notification": "unpackage/res/icons/20x20.png",
"notification@2x": "unpackage/res/icons/40x40.png",
"proapp@2x": "unpackage/res/icons/167x167.png",
"settings": "unpackage/res/icons/29x29.png",
"settings@2x": "unpackage/res/icons/58x58.png",
"spotlight": "unpackage/res/icons/40x40.png",
"spotlight@2x": "unpackage/res/icons/80x80.png"
},
"iphone": {
"app@2x": "unpackage/res/icons/120x120.png",
"app@3x": "unpackage/res/icons/180x180.png",
"notification@2x": "unpackage/res/icons/40x40.png",
"notification@3x": "unpackage/res/icons/60x60.png",
"settings@2x": "unpackage/res/icons/58x58.png",
"settings@3x": "unpackage/res/icons/87x87.png",
"spotlight@2x": "unpackage/res/icons/80x80.png",
"spotlight@3x": "unpackage/res/icons/120x120.png"
}
}
}
}
},
"h5": {

View File

@@ -0,0 +1,217 @@
<script setup lang="ts">
/**
* 图文详情简化版排版预览rich-text+ 一键发布 + 版本记录入口 + 删除
* 预览用文章存储的 theme_color 做主题色点缀;完整 40 套主题排版以 PC 编辑器为准
*/
import { computed, ref } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
import {
deleteWechatArticle,
getWechatArticleInfo,
getWechatArticlePublishStatus,
publishWechatArticle,
} from '@/api/media'
import AppBottomBar from '@/components/AppBottomBar.vue'
import AppDetailHero from '@/components/AppDetailHero.vue'
import AppGlassCard from '@/components/AppGlassCard.vue'
import AppInfoGroup, { type AppInfoRow } from '@/components/AppInfoGroup.vue'
import AppPageAtmosphere from '@/components/AppPageAtmosphere.vue'
import { renderSimpleHtml } from '../../utils/markdown'
const id = ref(0)
const info = ref<Record<string, any>>({})
const publishing = ref(false)
onLoad((q) => { id.value = Number(q?.id || 0) })
// onShow 而非 onLoad 加载:从版本页回退回来自动刷新最新内容
onShow(() => load())
async function load() {
info.value = await getWechatArticleInfo(id.value) || {}
}
const title = computed(() => info.value.title || `图文#${id.value}`)
const status = computed(() => Number(info.value.status ?? 0))
/** 状态 → hero 角标风格 */
const statusType = computed(() => {
if (status.value === 2) return 'success'
if (status.value === 3) return 'danger'
if (status.value === 1) return 'warning'
return 'default'
})
/** 简化版预览 HTMLrich-text 只认受限标签 + 内联样式) */
const previewHtml = computed(() =>
renderSimpleHtml(info.value.content_md || '', info.value.theme_color || '#07c160'),
)
const rows = computed<AppInfoRow[]>(() => [
{ label: '公众号', value: info.value.account_name || '未绑定(发布时用默认账号)' },
{ label: '作者', value: info.value.author || '-' },
{ label: '摘要', value: info.value.digest || '-' },
{ label: '状态', value: info.value.status_text || '-' },
{ label: '版本', value: `v${info.value.version_no ?? 0}` },
{ label: '最后修改', value: `${info.value.updated_by_name || info.value.created_by_name || '-'} ${info.value.updated_at || info.value.created_at || ''}` },
])
/**
* 一键发布:确认后用简化排版 HTML 提交(后端负责搬图/封面/草稿/审核)
* 发布中状态下主按钮变为「刷新状态」查审核结果
*/
function onPrimary() {
// 发布请求进行中禁止重复触发(连点防抖)
if (publishing.value) return
if (status.value === 1) {
refreshStatus()
return
}
if (!info.value.cover_url) {
uni.showToast({ title: '缺少封面图,请先在 PC 端设置', icon: 'none' })
return
}
uni.showModal({
title: '发布确认',
content: `将以简化排版发布到「${info.value.account_name || '默认账号'}」,完整主题排版请在 PC 端发布。继续吗?`,
success: async (res) => {
if (!res.confirm) return
publishing.value = true
try {
await publishWechatArticle({
id: id.value,
content_html: previewHtml.value,
})
uni.showToast({ title: '已提交发布,审核中', icon: 'success' })
load()
} finally {
publishing.value = false
}
},
})
}
/** 主动查询发布审核状态freepublish 异步审核) */
async function refreshStatus() {
const res = await getWechatArticlePublishStatus(id.value)
uni.showToast({ title: `当前状态:${res?.status_text || '未知'}`, icon: 'none' })
load()
}
/** 版本记录页(可回退) */
function goVersions() {
uni.navigateTo({
url: `/pages-sub/media/pages/article/versions?id=${id.value}&title=${encodeURIComponent(title.value)}`,
})
}
/** 编辑图文:移动端轻量表单(标题/摘要/正文;排版主题在 PC 编辑器) */
function goEdit() {
uni.navigateTo({ url: `/pages-sub/media/pages/article/form?id=${id.value}` })
}
/** 已发布文章链接复制rich-text 内无法点链接) */
function copyUrl() {
if (!info.value.article_url) return
uni.setClipboardData({
data: info.value.article_url,
success: () => uni.showToast({ title: '链接已复制', icon: 'success' }),
})
}
function onDelete() {
uni.showModal({
title: '删除图文',
content: `确定删除「${title.value}」吗?删除会记录到版本流水`,
confirmColor: '#ef4444',
success: async (res) => {
if (!res.confirm) return
await deleteWechatArticle([id.value])
uni.showToast({ title: '已删除', icon: 'success' })
setTimeout(() => uni.navigateBack(), 400)
},
})
}
</script>
<template>
<AppPageAtmosphere>
<view class="nl-page page">
<AppDetailHero
:title="title"
:subtitle="info.account_name || ''"
icon-text=""
:status-text="info.status_text || '草稿'"
:status-type="statusType"
/>
<AppInfoGroup title="图文信息" :items="rows" />
<!-- 版本记录入口底栏让位给编辑版本从这里进 -->
<AppGlassCard variant="tint" class="ver-card" padding="24rpx" pressable @tap="goVersions">
<view class="ver-card__row">
<text class="ver-card__label">版本记录</text>
<text class="ver-card__value">v{{ info.version_no ?? 0 }} · 查看/回退 </text>
</view>
</AppGlassCard>
<!-- 已发布提供线上链接复制入口 -->
<AppGlassCard v-if="info.article_url" variant="tint" class="url-card" padding="24rpx" pressable @tap="copyUrl">
<text class="url-card__label">线上文章链接点击复制</text>
<text class="url-card__value">{{ info.article_url }}</text>
</AppGlassCard>
<!-- 发布失败原因 -->
<AppGlassCard v-if="status === 3 && info.publish_error" variant="tint" class="err-card" padding="24rpx">
<text class="err-card__label">失败原因</text>
<text class="err-card__value">{{ info.publish_error }}</text>
</AppGlassCard>
<!-- 正文预览白底还原公众号阅读环境 -->
<view class="preview">
<view class="preview__head">
<text class="preview__title">正文预览</text>
<text class="preview__tip">简化排版 · 完整效果以 PC 为准</text>
</view>
<view class="preview__paper">
<rich-text :nodes="previewHtml" />
</view>
</view>
<AppBottomBar
:primary-text="status === 1 ? '刷新状态' : '发布'"
secondary-text="编辑"
danger-text="删除"
:loading="publishing"
@primary="onPrimary"
@secondary="goEdit"
@danger="onDelete"
/>
</view>
</AppPageAtmosphere>
</template>
<style scoped lang="scss">
.page { padding-bottom: 200rpx; }
.ver-card { margin: 0 28rpx 20rpx; }
.ver-card__row { display: flex; align-items: center; justify-content: space-between; }
.ver-card__label { font-size: 26rpx; font-weight: 600; }
.ver-card__value { font-size: 24rpx; color: $nl-color-text-secondary; }
.url-card { margin: 0 28rpx 20rpx; }
.url-card__label { display: block; font-size: 24rpx; color: $nl-color-text-secondary; margin-bottom: 8rpx; }
.url-card__value {
display: block; font-size: 24rpx; color: $nl-color-primary;
word-break: break-all;
}
.err-card { margin: 0 28rpx 20rpx; }
.err-card__label { display: block; font-size: 24rpx; color: #ef4444; margin-bottom: 8rpx; font-weight: 600; }
.err-card__value { display: block; font-size: 24rpx; color: $nl-color-text-secondary; }
.preview { margin: 0 28rpx; }
.preview__head {
display: flex; justify-content: space-between; align-items: center;
padding: 8rpx 8rpx 16rpx;
}
.preview__title { font-size: 28rpx; font-weight: 700; }
.preview__tip { font-size: 22rpx; color: $nl-color-text-muted; }
/* 预览纸面固定白底:公众号正文就是白底,不跟随暗色主题 */
.preview__paper {
background: #ffffff;
border-radius: 20rpx;
padding: 28rpx 24rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
}
</style>

View File

@@ -0,0 +1,146 @@
<script setup lang="ts">
/**
* 图文新建/编辑(移动端轻量版):标题/作者/摘要/发布账号/Markdown 正文
* 排版主题、封面图、多平台复制等重排版能力在 PC 编辑器;这里满足「路上快速起稿/改稿」
*/
import { computed, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import {
createWechatArticle,
getWechatAccountOption,
getWechatArticleInfo,
updateWechatArticle,
} from '@/api/media'
import AppPageForm from '@/components/AppPageForm.vue'
const id = ref(0)
const submitting = ref(false)
/** 可选发布账号(第一项固定「使用默认账号」= account_id 0 */
const accounts = ref<any[]>([])
const form = ref({
account_id: 0,
title: '',
author: '',
digest: '',
content_md: '',
})
const isEdit = computed(() => !!id.value)
/** 当前所选账号的展示名 */
const accountLabel = computed(() => {
if (!form.value.account_id) return '使用默认账号'
const hit = accounts.value.find(item => Number(item.id) === Number(form.value.account_id))
return hit ? hit.name : `账号#${form.value.account_id}`
})
onLoad(async (q) => {
id.value = Number(q?.id || 0)
// 账号下拉与详情并行加载,减少表单可交互前的等待
const tasks: Promise<any>[] = [getWechatAccountOption()]
if (id.value) tasks.push(getWechatArticleInfo(id.value))
const [options, detail] = await Promise.all(tasks)
accounts.value = Array.isArray(options) ? options : options?.items || []
if (detail) {
form.value = {
account_id: Number(detail.account_id || 0),
title: detail.title || '',
author: detail.author || '',
digest: detail.digest || '',
content_md: detail.content_md || '',
}
}
})
/** 选择发布账号ActionSheet 足够轻量(账号一般只有几个) */
function pickAccount() {
const items = ['使用默认账号', ...accounts.value.map(item => (item.is_default === 1 ? `${item.name}(默认)` : item.name))]
uni.showActionSheet({
itemList: items,
success: (res) => {
form.value.account_id = res.tapIndex === 0 ? 0 : Number(accounts.value[res.tapIndex - 1]?.id || 0)
},
})
}
/** 提交:新建走 create编辑走 update后端自动落版本流水 */
async function onSubmit() {
if (!form.value.title.trim()) {
uni.showToast({ title: '请填写文章标题', icon: 'none' })
return
}
if (!form.value.content_md.trim()) {
uni.showToast({ title: '正文不能为空', icon: 'none' })
return
}
submitting.value = true
try {
if (isEdit.value) await updateWechatArticle({ id: id.value, ...form.value })
else await createWechatArticle({ ...form.value })
uni.showToast({ title: '保存成功', icon: 'success' })
setTimeout(() => uni.navigateBack(), 400)
} finally {
submitting.value = false
}
}
</script>
<template>
<AppPageForm
tip="正文支持 Markdown 语法;排版主题与封面图请在 PC 端「图文创作」编辑器里设置。"
:loading="submitting"
@submit="onSubmit"
>
<view class="form-item">
<text class="label">标题</text>
<input v-model="form.title" class="input" :maxlength="64" placeholder="文章标题(必填)" />
</view>
<view class="form-item">
<text class="label">作者</text>
<input v-model="form.author" class="input" :maxlength="16" placeholder="显示在标题下方(选填)" />
</view>
<view class="form-item">
<text class="label">摘要</text>
<input v-model="form.digest" class="input" :maxlength="120" placeholder="转发卡片摘要(选填)" />
</view>
<view class="form-item" @tap="pickAccount">
<text class="label">发布账号</text>
<text class="value">{{ accountLabel }}</text>
</view>
<view class="form-block is-last">
<text class="label">正文Markdown</text>
<!-- auto-height 让编辑区随内容增长长文不用在小窗口里滚动 -->
<textarea
v-model="form.content_md"
class="content-area"
auto-height
:maxlength="-1"
placeholder="## 小节标题&#10;正文段落,支持 **加粗**、列表、引用、代码块等 Markdown 语法"
/>
</view>
</AppPageForm>
</template>
<style scoped lang="scss">
.form-item {
display: flex; align-items: center; justify-content: space-between;
min-height: 100rpx; border-bottom: 1rpx solid $nl-color-border;
}
.label { width: 220rpx; color: $nl-color-text-secondary; font-size: 28rpx; }
.input, .value { flex: 1; text-align: right; font-size: 28rpx; }
/* 正文块:标签在上、编辑区在下,给足书写空间 */
.form-block { padding: 20rpx 0 24rpx; }
.form-block.is-last { border-bottom: none; }
.form-block .label { display: block; width: auto; margin-bottom: 12rpx; }
.content-area {
width: 100%;
min-height: 480rpx;
font-size: 28rpx;
line-height: 1.8;
box-sizing: border-box;
padding: 20rpx;
border-radius: 16rpx;
background: $nl-color-bg-page;
}
</style>

View File

@@ -0,0 +1,164 @@
<script setup lang="ts">
/**
* 图文列表:搜索标题 + 状态筛选,点击进详情预览
* 移动端不做排版编辑40 套主题排版在 PC 编辑器),这里是浏览/发布/回退入口
*/
import { computed, ref } from 'vue'
import { onReachBottom, onShow } from '@dcloudio/uni-app'
import { getWechatArticleList } from '@/api/media'
import AppCardList, { type AppCardItem } from '@/components/AppCardList.vue'
import AppEmpty from '@/components/AppEmpty.vue'
import AppFab from '@/components/AppFab.vue'
import AppFilterBar from '@/components/AppFilterBar.vue'
import AppLoading from '@/components/AppLoading.vue'
import AppPageAtmosphere from '@/components/AppPageAtmosphere.vue'
import AppSearchBar from '@/components/AppSearchBar.vue'
import AppSkeleton from '@/components/AppSkeleton.vue'
import { usePullRefresh } from '@/composables/usePullRefresh'
const loading = ref(false)
const list = ref<any[]>([])
const keyword = ref('')
const statusFilter = ref<number | ''>('')
const draftStatus = ref<number | ''>('')
const page = ref(1)
const finished = ref(false)
/** 状态字典:对齐后端 WechatArticleStatusEnum0草稿 1发布中 2已发布 3发布失败 */
const STATUS_CHIPS: { label: string, value: number | '' }[] = [
{ label: '全部', value: '' },
{ label: '草稿', value: 0 },
{ label: '发布中', value: 1 },
{ label: '已发布', value: 2 },
{ label: '失败', value: 3 },
]
/** 状态 → 卡片角标风格 */
function statusType(status: number): 'default' | 'success' | 'danger' | 'warning' {
if (status === 2) return 'success'
if (status === 3) return 'danger'
if (status === 1) return 'warning'
return 'default'
}
const selectedCount = computed(() => (statusFilter.value === '' ? 0 : 1))
const cards = computed<AppCardItem[]>(() =>
list.value.map(item => ({
id: item.id,
title: item.title || `图文#${item.id}`,
subtitle: [item.account_name, `v${item.version_no}`, item.updated_at || item.created_at]
.filter(Boolean)
.join(' · '),
avatar: item.cover_url || '',
iconText: '文',
statusText: item.status_text || '草稿',
statusType: statusType(Number(item.status)),
})),
)
onShow(() => reload())
usePullRefresh(reload)
onReachBottom(() => loadMore())
/** 重置分页并重新加载(下拉刷新/筛选变化) */
async function reload() {
page.value = 1
finished.value = false
list.value = []
await loadMore()
}
/** 分页加载:标题模糊 + 状态过滤 */
async function loadMore() {
if (loading.value || finished.value) return
loading.value = true
try {
const res = await getWechatArticleList({
page: page.value,
pageSize: 20,
title: keyword.value.trim() || undefined,
status: statusFilter.value === '' ? undefined : statusFilter.value,
})
const items = res?.items || res?.data || []
list.value = page.value === 1 ? items : list.value.concat(items)
const total = Number(res?.total || 0)
if (!items.length || list.value.length >= total) finished.value = true
else page.value += 1
} finally {
loading.value = false
}
}
function onFilterReset() {
draftStatus.value = ''
}
function onFilterConfirm() {
statusFilter.value = draftStatus.value
reload()
}
/** 进入详情预览页 */
function goDetail(item: AppCardItem) {
uni.navigateTo({ url: `/pages-sub/media/pages/article/detail?id=${item.id}` })
}
/** 新建图文:移动端轻量表单(排版主题在 PC 编辑器配) */
function goCreate() {
uni.navigateTo({ url: '/pages-sub/media/pages/article/form' })
}
</script>
<template>
<AppPageAtmosphere tone="cool">
<view class="nl-page page">
<AppSearchBar
v-model="keyword"
placeholder="搜索图文标题"
@search="(v) => { keyword = v; reload() }"
@clear="() => { keyword = ''; reload() }"
/>
<AppFilterBar :selected-count="selectedCount" @reset="onFilterReset" @confirm="onFilterConfirm">
<view class="filter-row">
<text>状态</text>
<view class="chips">
<text
v-for="chip in STATUS_CHIPS"
:key="String(chip.value)"
class="chip"
:class="{ 'is-on': draftStatus === chip.value }"
@tap="draftStatus = chip.value"
>{{ chip.label }}</text>
</view>
</view>
</AppFilterBar>
<AppSkeleton v-if="loading && !cards.length" :rows="5" />
<AppCardList v-else-if="cards.length" :list="cards" @select="goDetail" />
<AppEmpty v-else text="暂无图文,点右下角「新建」快速起稿" />
<AppLoading v-if="loading && cards.length" text="加载更多…" />
<view v-else-if="cards.length" class="more" @tap="loadMore">
{{ finished ? '没有更多了' : '加载更多' }}
</view>
<AppFab text="新建" @tap="goCreate" />
</view>
</AppPageAtmosphere>
</template>
<style scoped lang="scss">
/* 底部预留 FAB 高度,避免最后一张卡片被悬浮按钮遮住 */
.page { padding-bottom: 160rpx; }
.filter-row {
display: flex; align-items: center; justify-content: space-between;
min-height: 72rpx; margin-bottom: 8rpx; font-size: 26rpx;
}
.chips { display: flex; gap: 12rpx; flex-wrap: wrap; }
.chip {
padding: 8rpx 20rpx; border-radius: 999rpx; background: $nl-color-bg-page;
color: $nl-color-text-secondary; font-size: 24rpx;
}
.chip.is-on { background: $nl-color-primary-soft; color: $nl-color-primary; }
.more { text-align: center; color: $nl-color-text-muted; padding: 16rpx; font-size: 24rpx; }
</style>

View File

@@ -0,0 +1,104 @@
<script setup lang="ts">
/**
* 版本记录页:展示操作流水(谁在什么时间做了什么),确认后可回退到任一版本
* 回退后返回详情页会自动刷新(详情页 onShow 重新加载)
*/
import { computed, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { getWechatArticleVersionList, rollbackWechatArticle } from '@/api/media'
import AppCardList, { type AppCardItem } from '@/components/AppCardList.vue'
import AppEmpty from '@/components/AppEmpty.vue'
import AppPageAtmosphere from '@/components/AppPageAtmosphere.vue'
import AppSkeleton from '@/components/AppSkeleton.vue'
import { usePullRefresh } from '@/composables/usePullRefresh'
const articleId = ref(0)
const articleTitle = ref('')
const loading = ref(false)
const versions = ref<any[]>([])
/** 版本动作 → 角标风格1创建2修改3删除4回退5发布 */
function actionType(action: number): 'default' | 'success' | 'danger' | 'warning' {
if (action === 1 || action === 5) return 'success'
if (action === 3) return 'danger'
if (action === 4) return 'warning'
return 'default'
}
const cards = computed<AppCardItem[]>(() =>
versions.value.map(item => ({
id: item.id,
title: `v${item.version_no} ${item.title || ''}`,
subtitle: `${item.operator_name || '未知操作人'} · ${item.created_at || ''}`,
iconText: `v${item.version_no}`,
statusText: item.action_text || '',
statusType: actionType(Number(item.action)),
})),
)
onLoad((q) => {
articleId.value = Number(q?.id || 0)
articleTitle.value = decodeURIComponent(String(q?.title || ''))
load()
})
usePullRefresh(load)
async function load() {
loading.value = true
try {
const res = await getWechatArticleVersionList({
article_id: articleId.value,
page: 1,
pageSize: 100,
})
versions.value = res?.items || res?.data || []
} finally {
loading.value = false
}
}
/**
* 回退确认:当前内容会先存成新版本再覆盖,误操作可再次回退找回
*/
function onSelect(item: AppCardItem) {
const row = versions.value.find(v => Number(v.id) === Number(item.id))
if (!row) return
uni.showModal({
title: '回退确认',
content: `回退到 v${row.version_no}${row.action_text} · ${row.created_at})?当前内容会先保存为新版本`,
confirmText: '回退',
confirmColor: '#f59e0b',
success: async (res) => {
if (!res.confirm) return
await rollbackWechatArticle({ id: articleId.value, version_id: Number(row.id) })
uni.showToast({ title: `已回退到 v${row.version_no}`, icon: 'success' })
load()
},
})
}
</script>
<template>
<AppPageAtmosphere tone="cool">
<view class="nl-page page">
<view class="head">
<text class="head__title">{{ articleTitle || '版本记录' }}</text>
<text class="head__tip">点击任一版本可回退</text>
</view>
<AppSkeleton v-if="loading && !cards.length" :rows="5" />
<AppCardList v-else-if="cards.length" :list="cards" @select="onSelect" />
<AppEmpty v-else text="暂无版本记录" />
</view>
</AppPageAtmosphere>
</template>
<style scoped lang="scss">
.page { padding-bottom: 48rpx; }
.head { padding: 0 36rpx 12rpx; }
.head__title {
display: block; font-size: 32rpx; font-weight: 700;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.head__tip { display: block; font-size: 24rpx; color: $nl-color-text-muted; margin-top: 6rpx; }
</style>

View File

@@ -0,0 +1,63 @@
/**
* 移动端简化版 Markdown 渲染:给 rich-text 组件与移动端发布用
*
* 为什么是「简化版」:
* - 小程序 rich-text 只支持受限标签 + 内联样式(不支持 <a>),无法承载 PC 端 60 套主题的全部细节
* - 移动端发布场景是「PC 排好版,手机上应急发布」,用主题色点缀的通用排版已够用
* - 完整排版请在 PC 编辑器渲染后发布(两端调用同一个 publish 接口)
*/
import MarkdownIt from 'markdown-it'
/** 单例渲染器html:false 防 XSS正文唯一输入源是 Markdown */
const md = new MarkdownIt({ html: false, linkify: true, breaks: true })
/**
* Markdown → 带内联样式的简化 HTML
* @param markdown 正文 Markdown
* @param accent 主题色(取文章存储的 theme_color保持品牌色一致
*/
export function renderSimpleHtml(markdown: string, accent = '#07c160'): string {
let html = md.render(markdown || '')
// rich-text 不支持 <a>,降级为主题色 span点击无跳转但文字可读
html = html.replace(/<a\s[^>]*>/g, `<span style="color: ${accent};">`).replace(/<\/a>/g, '</span>')
// 基础排版rich-text 只认内联样式,逐标签注入
const styleMap: Record<string, string> = {
h1: `font-size: 20px; font-weight: bold; color: #1a1a1a; line-height: 1.4; margin: 20px 0 12px;`,
h2: `font-size: 17px; font-weight: bold; color: #1a1a1a; line-height: 1.5; margin: 20px 0 10px; padding-left: 10px; border-left: 4px solid ${accent};`,
h3: `font-size: 15px; font-weight: bold; color: #333333; line-height: 1.5; margin: 16px 0 8px;`,
p: `font-size: 15px; line-height: 1.8; color: #3f3f3f; margin: 0 0 12px;`,
blockquote: `margin: 12px 0; padding: 8px 12px; border-left: 3px solid ${accent}; background: rgba(0, 0, 0, 0.03);`,
ul: `margin: 0 0 12px; padding-left: 20px;`,
ol: `margin: 0 0 12px; padding-left: 20px;`,
li: `font-size: 15px; line-height: 1.8; color: #3f3f3f;`,
strong: `font-weight: bold; color: ${accent};`,
code: `font-size: 13px; color: ${accent}; background: rgba(0, 0, 0, 0.05); padding: 1px 4px; border-radius: 3px;`,
pre: `background: #2d2d2d; border-radius: 6px; padding: 10px 12px; overflow-x: auto; margin: 0 0 12px; color: #e8e8e8; font-size: 12px;`,
hr: `border: none; border-top: 1px solid #e5e5e5; margin: 20px 0;`,
img: `max-width: 100%; border-radius: 6px; display: block; margin: 12px auto;`,
table: `border-collapse: collapse; width: 100%; margin: 0 0 12px; font-size: 13px;`,
th: `border: 1px solid #e5e5e5; padding: 6px 8px; font-weight: bold; text-align: left;`,
td: `border: 1px solid #e5e5e5; padding: 6px 8px;`,
}
Object.keys(styleMap).forEach((tag) => {
// 只处理无属性的起始标签markdown-it 输出的标签基本无属性img 单独处理)
html = html.replaceAll(`<${tag}>`, `<${tag} style="${styleMap[tag]}">`)
})
// img 标签带 src/alt 属性,追加样式而不是整标签替换
html = html.replace(/<img\s/g, `<img style="${styleMap.img}" `)
// 连续图片两列拼接:纯图片段落(只有 img/换行)改为 inline-block 两列,
// 与 PC 端「智能相册」对齐rich-text 不支持 flex用 inline-block 兼容三端)
html = html.replace(/<p style="[^"]*">((?:\s|<br\s*\/?>|<img [^>]*>)+)<\/p>/g, (whole, inner: string) => {
const imgs = inner.match(/<img [^>]*>/g) ?? []
if (imgs.length < 2) return whole
const cells = imgs.map((tag, i) => {
// 奇数张时最后一张保持整行(自然比例),避免孤图缩成半宽
if (imgs.length % 2 === 1 && i === imgs.length - 1) return tag
const cellStyle = `width: 49%; display: inline-block; vertical-align: top; border-radius: 6px; margin: 0 ${i % 2 === 0 ? '2%' : '0'} 6px 0;`
return tag.replace(/style="[^"]*"/, `style="${cellStyle}"`)
})
// font-size: 0 消除 inline-block 之间的空白缝隙
return `<div style="margin: 12px 0; font-size: 0;">${cells.join('')}</div>`
})
return html
}

View File

@@ -0,0 +1,73 @@
<script setup lang="ts">
/**
* 登录日志:时间线条目卡片
*/
import { computed, ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { getLoginLogApi } from '@/api/auth'
import AppCardList, { type AppCardItem } from '@/components/AppCardList.vue'
import AppEmpty from '@/components/AppEmpty.vue'
import AppPageAtmosphere from '@/components/AppPageAtmosphere.vue'
import { usePullRefresh } from '@/composables/usePullRefresh'
const list = ref<any[]>([])
const loading = ref(false)
const page = ref(1)
const finished = ref(false)
const cards = computed<AppCardItem[]>(() =>
list.value.map((item) => ({
id: item.id,
title: item.ip || item.account || '登录记录',
subtitle: `${item.created_at || ''} · ${item.ua || item.user_agent || ''}`.trim(),
statusText: Number(item.status) === 1 || item.success === false ? '失败' : '成功',
statusType: Number(item.status) === 1 || item.success === false ? 'danger' : 'success',
})),
)
/** 下拉/进页:从第一页重载 */
async function reload() {
page.value = 1
finished.value = false
list.value = []
await loadMore()
}
onShow(() => {
reload()
})
usePullRefresh(reload)
async function loadMore() {
if (loading.value || finished.value) return
loading.value = true
try {
const res = await getLoginLogApi({ page: page.value, pageSize: 20 })
const items = res?.items || res?.data || []
list.value = page.value === 1 ? items : list.value.concat(items)
const total = Number(res?.total || 0)
if (!items.length || list.value.length >= total) finished.value = true
else page.value += 1
} finally {
loading.value = false
}
}
</script>
<template>
<!-- 氛围壳提供主题作用域 + 背景色斑玻璃卡的模糊才有内容可透 -->
<AppPageAtmosphere>
<view class="nl-page">
<AppCardList v-if="cards.length" :list="cards" />
<AppEmpty v-else :text="loading ? '加载中…' : '暂无登录日志'" />
<view v-if="cards.length" class="more" @tap="loadMore">
{{ finished ? '没有更多了' : loading ? '加载中' : '加载更多' }}
</view>
</view>
</AppPageAtmosphere>
</template>
<style scoped lang="scss">
.more { text-align: center; color: $nl-color-text-muted; padding: 24rpx; font-size: 24rpx; }
</style>

View File

@@ -0,0 +1,72 @@
<script setup lang="ts">
/**
* 操作日志:卡片列表
*/
import { computed, ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { getOpLogApi } from '@/api/auth'
import AppCardList, { type AppCardItem } from '@/components/AppCardList.vue'
import AppEmpty from '@/components/AppEmpty.vue'
import AppPageAtmosphere from '@/components/AppPageAtmosphere.vue'
import { usePullRefresh } from '@/composables/usePullRefresh'
const list = ref<any[]>([])
const loading = ref(false)
const page = ref(1)
const finished = ref(false)
const cards = computed<AppCardItem[]>(() =>
list.value.map((item) => ({
id: item.id,
title: item.endpoint_name || item.name || item.url || '操作记录',
subtitle: `${item.created_at || ''} · ${item.method || ''} ${item.url || ''}`.trim(),
statusText: item.ip || '',
})),
)
/** 下拉/进页:从第一页重载 */
async function reload() {
page.value = 1
finished.value = false
list.value = []
await loadMore()
}
onShow(() => {
reload()
})
usePullRefresh(reload)
async function loadMore() {
if (loading.value || finished.value) return
loading.value = true
try {
const res = await getOpLogApi({ page: page.value, pageSize: 20 })
const items = res?.items || res?.data || []
list.value = page.value === 1 ? items : list.value.concat(items)
const total = Number(res?.total || 0)
if (!items.length || list.value.length >= total) finished.value = true
else page.value += 1
} finally {
loading.value = false
}
}
</script>
<template>
<!-- 氛围壳提供主题作用域 + 背景色斑玻璃卡的模糊才有内容可透 -->
<AppPageAtmosphere>
<view class="nl-page">
<AppCardList v-if="cards.length" :list="cards" />
<AppEmpty v-else :text="loading ? '加载中…' : '暂无操作日志'" />
<view v-if="cards.length" class="more" @tap="loadMore">
{{ finished ? '没有更多了' : loading ? '加载中' : '加载更多' }}
</view>
</view>
</AppPageAtmosphere>
</template>
<style scoped lang="scss">
.more { text-align: center; color: $nl-color-text-muted; padding: 24rpx; font-size: 24rpx; }
</style>

View File

@@ -0,0 +1,119 @@
<script setup lang="ts">
/**
* 编辑资料:昵称/手机/邮箱/头像上传
*/
import { ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { updateProfileApi } from '@/api/auth'
import { uploadImage } from '@/api/upload'
import AppPageAtmosphere from '@/components/AppPageAtmosphere.vue'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
const form = ref({
nick_name: '',
phone: '',
email: '',
avatar: '',
})
const submitting = ref(false)
onShow(() => {
userStore.hydrateFromStorage()
form.value = {
nick_name: userStore.userInfo.nick_name || '',
phone: userStore.userInfo.phone || '',
email: userStore.userInfo.email || '',
avatar: userStore.userInfo.avatar || '',
}
})
/** 选图并上传到 OSS */
function chooseAvatar() {
uni.chooseImage({
count: 1,
success: async (res) => {
const path = res.tempFilePaths?.[0]
if (!path) return
try {
uni.showLoading({ title: '上传中' })
const result = await uploadImage(path)
form.value.avatar = result?.url || result?.path || result || ''
uni.showToast({ title: '上传成功', icon: 'success' })
} catch {
// toasted
} finally {
uni.hideLoading()
}
},
})
}
async function onSave() {
submitting.value = true
try {
await updateProfileApi({ ...form.value })
await userStore.fetchUserInfo()
uni.showToast({ title: '已保存', icon: 'success' })
} finally {
submitting.value = false
}
}
</script>
<template>
<!-- 氛围壳提供主题作用域 + 背景色斑玻璃卡的模糊才有内容可透 -->
<AppPageAtmosphere>
<view class="nl-page page">
<view class="avatar-block" @tap="chooseAvatar">
<image v-if="form.avatar" class="avatar" :src="form.avatar" mode="aspectFill" />
<view v-else class="avatar avatar--empty">头像</view>
<text class="tip">点击更换头像</text>
</view>
<view class="nl-card form">
<view class="form-item">
<text class="label">昵称</text>
<input v-model="form.nick_name" class="input" placeholder="昵称" />
</view>
<view class="form-item">
<text class="label">手机</text>
<input v-model="form.phone" class="input" placeholder="手机号" />
</view>
<view class="form-item">
<text class="label">邮箱</text>
<input v-model="form.email" class="input" placeholder="邮箱" />
</view>
</view>
<view class="save" :class="{ 'is-disabled': submitting }" @tap="onSave">
{{ submitting ? '保存中' : '保存' }}
</view>
</view>
</AppPageAtmosphere>
</template>
<style scoped lang="scss">
/* 顶部 24rpx 与全局 .nl-page 标准一致 */
.page { padding: 24rpx 24rpx 24rpx; }
.avatar-block {
display: flex; flex-direction: column; align-items: center; margin-bottom: 32rpx;
}
.avatar {
width: 160rpx; height: 160rpx; border-radius: 50%; background: $nl-color-primary-soft;
}
.avatar--empty {
display: flex; align-items: center; justify-content: center; color: $nl-color-primary;
}
.tip { margin-top: 12rpx; font-size: 24rpx; color: $nl-color-text-muted; }
.form-item {
display: flex; align-items: center; min-height: 96rpx; border-bottom: 1rpx solid $nl-color-border;
}
.form-item:last-child { border-bottom: none; }
.label { width: 140rpx; color: $nl-color-text-secondary; font-size: 26rpx; }
.input { flex: 1; text-align: right; font-size: 28rpx; }
.save {
margin-top: 48rpx; height: 88rpx; border-radius: 999rpx; background: $nl-color-primary;
color: #fff; display: flex; align-items: center; justify-content: center; font-weight: 600;
}
.save.is-disabled { opacity: 0.7; }
</style>

View File

@@ -0,0 +1,8 @@
# my-gen 分包
粘贴后台代码生成 zip 中的 `uniapp/<dash-case>/` 到本目录。
同步:
1. `src/pages.json``subPackages``my-gen` 增加页面路径
2. `src/config/features.ts``FEATURE_LIST` 增加入口(`category: 'gen'`

View File

@@ -0,0 +1,43 @@
<script setup lang="ts">
/**
* 代码生成模块占位说明:粘贴后按 list/detail/form 三页注册
*/
</script>
<template>
<view class="nl-page page">
<view class="card">
<view class="title">如何接入生成代码</view>
<text class="line">1. PC代码生成下载 zip</text>
<text class="line">2. 解压 uniapp/&lt;dash-case&gt;/ 到 pages-sub/my-gen/&lt;dash-case&gt;/</text>
<text class="line">3. 合并 pages.jsonlist / detail / form 三页</text>
<text class="line">4. features.ts 登记入口category: gen</text>
<text class="line">5. 交互必须是列表 详情 全屏表单禁止抽屉主 CRUD</text>
<text class="line">6. 复用 AppCardList / AppDetailHero / AppInfoGroup / AppBottomBar / AppPageForm</text>
<text class="line">7. 需同时兼容微信小程序AppH5</text>
</view>
</view>
</template>
<style scoped lang="scss">
/* 顶部 24rpx 与全局 .nl-page 标准一致 */
.page { padding: 24rpx 28rpx 28rpx; }
.card {
background: $nl-color-bg-card;
border-radius: 32rpx;
padding: 32rpx 28rpx;
box-shadow: 0 12rpx 36rpx rgba(15, 23, 42, 0.05);
}
.title {
font-size: 32rpx;
font-weight: 700;
margin-bottom: 16rpx;
}
.line {
display: block;
font-size: 26rpx;
color: $nl-color-text-secondary;
line-height: 1.8;
margin-top: 8rpx;
}
</style>

View File

@@ -0,0 +1,96 @@
<script setup lang="ts">
/**
* 管理员详情:资料展示 + 底栏编辑 / 删除
*/
import { computed, ref } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
import { deleteAdmin, getAdminInfo } from '@/api/admin'
import AppBottomBar from '@/components/AppBottomBar.vue'
import AppDetailHero from '@/components/AppDetailHero.vue'
import AppEmpty from '@/components/AppEmpty.vue'
import AppInfoGroup, { type AppInfoRow } from '@/components/AppInfoGroup.vue'
import AppPageAtmosphere from '@/components/AppPageAtmosphere.vue'
const id = ref(0)
const loading = ref(false)
const info = ref<Record<string, any>>({})
onLoad((q) => {
id.value = Number(q?.id || 0)
})
onShow(() => {
if (id.value) loadDetail()
})
async function loadDetail() {
loading.value = true
try {
info.value = (await getAdminInfo(id.value)) || {}
} finally {
loading.value = false
}
}
// 后端没有 account 字段,登录账号即 phone
const title = computed(() => info.value.nick_name || info.value.phone || '管理员')
const statusText = computed(() => (Number(info.value.status) === 0 ? '正常' : '禁用'))
const statusType = computed(() => (Number(info.value.status) === 0 ? 'success' : 'danger'))
const baseRows = computed<AppInfoRow[]>(() => [
{ label: '手机(登录账号)', value: info.value.phone },
{ label: '昵称', value: info.value.nick_name },
{ label: '邮箱', value: info.value.email },
])
const metaRows = computed<AppInfoRow[]>(() => [
{ label: '角色 ID', value: info.value.role_id },
{ label: '创建时间', value: info.value.created_at },
{ label: '更新时间', value: info.value.updated_at },
])
function goEdit() {
uni.navigateTo({ url: `/pages-sub/system/pages/admin/form?id=${id.value}` })
}
function onDelete() {
uni.showModal({
title: '删除管理员',
content: `确定删除「${title.value}」吗?删除后不可恢复。`,
confirmColor: '#ef4444',
success: async (res) => {
if (!res.confirm) return
await deleteAdmin({ ids: [id.value] })
uni.showToast({ title: '已删除', icon: 'success' })
setTimeout(() => uni.navigateBack(), 400)
},
})
}
</script>
<template>
<!-- 氛围壳提供主题作用域 + 背景色斑玻璃卡的模糊才有内容可透 -->
<AppPageAtmosphere>
<view class="nl-page">
<AppEmpty v-if="!id" text="缺少管理员 ID" />
<template v-else>
<AppDetailHero
:title="title"
:subtitle="info.phone || ''"
:avatar="info.avatar || ''"
:status-text="statusText"
:status-type="statusType"
/>
<AppInfoGroup title="基本资料" :items="baseRows" />
<AppInfoGroup title="其它信息" :items="metaRows" />
<AppBottomBar
primary-text="编辑资料"
danger-text="删除"
@primary="goEdit"
@danger="onDelete"
/>
</template>
</view>
</AppPageAtmosphere>
</template>

View File

@@ -0,0 +1,141 @@
<script setup lang="ts">
/**
* 管理员新建/编辑全屏表单
*/
import { computed, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { createAdmin, getAdminInfo, updateAdmin } from '@/api/admin'
import { getRoleOption } from '@/api/role'
import AppPageForm from '@/components/AppPageForm.vue'
const id = ref(0)
const submitting = ref(false)
const roleOptions = ref<{ label: string; value: number }[]>([])
// 后端 AdminController insertField = phone,nick_name,password,role_id账号即手机号
// 之前多出的 account 是幽灵字段,后端不认,会导致「只填账号不填手机」时创建失败
const form = ref<Record<string, any>>({
nick_name: '',
phone: '',
password: '',
role_id: undefined as number | undefined,
status: 0,
})
const isEdit = computed(() => !!id.value)
const tip = computed(() =>
isEdit.value ? '修改后立即生效,请谨慎调整状态与角色。' : '创建后可用账号密码登录管理端。',
)
onLoad(async (q) => {
id.value = Number(q?.id || 0)
await loadRoles()
if (id.value) await loadInfo()
})
async function loadRoles() {
try {
const res = await getRoleOption({})
const items = Array.isArray(res) ? res : res?.items || res?.data || []
roleOptions.value = items.map((r: any) => ({
label: r.name || r.label || `角色${r.id}`,
value: Number(r.id ?? r.value),
}))
if (!form.value.role_id && roleOptions.value[0]) {
form.value.role_id = roleOptions.value[0].value
}
} catch {
roleOptions.value = []
}
}
async function loadInfo() {
const info = await getAdminInfo(id.value)
form.value = {
nick_name: info?.nick_name || '',
phone: info?.phone || '',
password: '',
role_id: info?.role_id,
status: Number(info?.status || 0),
}
}
const roleLabel = computed(
() => roleOptions.value.find((r) => r.value === form.value.role_id)?.label || '请选择角色',
)
function pickRole() {
if (!roleOptions.value.length) {
uni.showToast({ title: '暂无角色', icon: 'none' })
return
}
uni.showActionSheet({
itemList: roleOptions.value.map((r) => r.label),
success: (res) => {
const opt = roleOptions.value[res.tapIndex]
if (opt) form.value.role_id = opt.value
},
})
}
async function onSubmit() {
if (!form.value.phone) {
uni.showToast({ title: '请填写手机号(登录账号)', icon: 'none' })
return
}
if (!isEdit.value && !form.value.password) {
uni.showToast({ title: '请填写初始密码', icon: 'none' })
return
}
submitting.value = true
try {
if (isEdit.value) {
await updateAdmin({ id: id.value, ...form.value })
} else {
await createAdmin({ ...form.value })
}
uni.showToast({ title: '保存成功', icon: 'success' })
setTimeout(() => uni.navigateBack(), 400)
} finally {
submitting.value = false
}
}
</script>
<template>
<AppPageForm :tip="tip" :loading="submitting" @submit="onSubmit">
<view class="form-item">
<text class="label">手机</text>
<input v-model="form.phone" class="input" :disabled="isEdit" placeholder="手机号(登录账号)" />
</view>
<view class="form-item">
<text class="label">昵称</text>
<input v-model="form.nick_name" class="input" placeholder="昵称" />
</view>
<view v-if="!isEdit" class="form-item">
<text class="label">密码</text>
<input v-model="form.password" class="input" password placeholder="初始密码" />
</view>
<view class="form-item" @tap="pickRole">
<text class="label">角色</text>
<text class="value">{{ roleLabel }}</text>
</view>
<view class="form-item is-last" @tap="form.status = form.status === 0 ? 1 : 0">
<text class="label">状态</text>
<text class="value">{{ form.status === 0 ? '正常' : '禁用' }}</text>
</view>
</AppPageForm>
</template>
<style scoped lang="scss">
.form-item {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 100rpx;
border-bottom: 1rpx solid $nl-color-border;
}
.form-item.is-last { border-bottom: none; }
.label { width: 140rpx; color: $nl-color-text-secondary; font-size: 28rpx; }
.input, .value { flex: 1; text-align: right; font-size: 28rpx; color: $nl-color-text; }
</style>

View File

@@ -0,0 +1,153 @@
<script setup lang="ts">
/**
* 管理员列表:仅浏览/筛选,点击进详情;新增进全屏表单
*/
import { computed, ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { getAdminList } from '@/api/admin'
import AppCardList, { type AppCardItem } from '@/components/AppCardList.vue'
import AppEmpty from '@/components/AppEmpty.vue'
import AppFab from '@/components/AppFab.vue'
import AppFilterBar from '@/components/AppFilterBar.vue'
import AppLoading from '@/components/AppLoading.vue'
import AppPageAtmosphere from '@/components/AppPageAtmosphere.vue'
import AppSearchBar from '@/components/AppSearchBar.vue'
import AppSkeleton from '@/components/AppSkeleton.vue'
import { usePullRefresh } from '@/composables/usePullRefresh'
const loading = ref(false)
const list = ref<any[]>([])
const keyword = ref('')
const draftKeyword = ref('')
const statusFilter = ref<number | ''>('')
const draftStatus = ref<number | ''>('')
const page = ref(1)
const finished = ref(false)
const selectedCount = computed(() => {
let n = 0
if (keyword.value) n += 1
if (statusFilter.value !== '') n += 1
return n
})
const cards = computed<AppCardItem[]>(() =>
list.value.map((item) => ({
id: item.id,
// 后端没有 account 字段,登录账号即 phone
title: item.nick_name || item.phone || `管理员#${item.id}`,
subtitle: item.phone || '',
avatar: item.avatar || '',
iconText: String(item.nick_name || '管').slice(0, 1),
statusText: Number(item.status) === 0 ? '正常' : '禁用',
statusType: Number(item.status) === 0 ? 'success' : 'danger',
})),
)
onShow(() => reload())
usePullRefresh(reload)
async function reload() {
page.value = 1
finished.value = false
list.value = []
await loadMore()
}
async function loadMore() {
if (loading.value || finished.value) return
loading.value = true
try {
// 后端可查字段为 phone/nick_name/emaillike没有 account
// 单一关键词按内容路由:纯数字按手机号查,否则按昵称查
const kw = keyword.value.trim()
const res = await getAdminList({
page: page.value,
pageSize: 20,
phone: kw && /^\d+$/.test(kw) ? kw : undefined,
nick_name: kw && !/^\d+$/.test(kw) ? kw : undefined,
status: statusFilter.value === '' ? undefined : statusFilter.value,
})
const items = res?.items || res?.data || []
list.value = page.value === 1 ? items : list.value.concat(items)
const total = Number(res?.total || 0)
if (!items.length || list.value.length >= total) finished.value = true
else page.value += 1
} finally {
loading.value = false
}
}
function onFilterReset() {
draftKeyword.value = ''
draftStatus.value = ''
}
function onFilterConfirm() {
keyword.value = draftKeyword.value
statusFilter.value = draftStatus.value
reload()
}
/** 进入详情 */
function goDetail(item: AppCardItem) {
uni.navigateTo({ url: `/pages-sub/system/pages/admin/detail?id=${item.id}` })
}
function goCreate() {
uni.navigateTo({ url: '/pages-sub/system/pages/admin/form' })
}
</script>
<template>
<AppPageAtmosphere tone="cool">
<view class="nl-page page">
<AppSearchBar
v-model="keyword"
placeholder="搜索账号或昵称"
@search="(v) => { keyword = v; reload() }"
@clear="() => { keyword = ''; reload() }"
/>
<AppFilterBar :selected-count="selectedCount" @reset="onFilterReset" @confirm="onFilterConfirm">
<view class="filter-row">
<text>关键词</text>
<input v-model="draftKeyword" class="filter-input" placeholder="账号" />
</view>
<view class="filter-row">
<text>状态</text>
<view class="chips">
<text class="chip" :class="{ 'is-on': draftStatus === '' }" @tap="draftStatus = ''">全部</text>
<text class="chip" :class="{ 'is-on': draftStatus === 0 }" @tap="draftStatus = 0">正常</text>
<text class="chip" :class="{ 'is-on': draftStatus === 1 }" @tap="draftStatus = 1">禁用</text>
</view>
</view>
</AppFilterBar>
<AppSkeleton v-if="loading && !cards.length" :rows="5" />
<AppCardList v-else-if="cards.length" :list="cards" @select="goDetail" />
<AppEmpty v-else text="暂无管理员" />
<AppLoading v-if="loading && cards.length" text="加载更多…" />
<view v-else-if="cards.length" class="more" @tap="loadMore">
{{ finished ? '没有更多了' : '加载更多' }}
</view>
<AppFab @tap="goCreate" />
</view>
</AppPageAtmosphere>
</template>
<style scoped lang="scss">
.page { padding-bottom: 160rpx; }
.filter-row {
display: flex; align-items: center; justify-content: space-between;
min-height: 72rpx; margin-bottom: 8rpx; font-size: 26rpx;
}
.filter-input { flex: 1; text-align: right; margin-left: 24rpx; }
.chips { display: flex; gap: 12rpx; }
.chip {
padding: 8rpx 20rpx; border-radius: 999rpx; background: $nl-color-bg-page;
color: $nl-color-text-secondary; font-size: 24rpx;
}
.chip.is-on { background: $nl-color-primary-soft; color: $nl-color-primary; }
.more { text-align: center; color: $nl-color-text-muted; padding: 16rpx; font-size: 24rpx; }
</style>

View File

@@ -0,0 +1,91 @@
<script setup lang="ts">
/**
* AI 密钥详情:设为默认 / 删除
*/
import { computed, ref } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
import { deleteAiKey, getAiKeyList, getAiRuntimeOptions, saveAiRuntime } from '@/api/system'
import AppBottomBar from '@/components/AppBottomBar.vue'
import AppDetailHero from '@/components/AppDetailHero.vue'
import AppInfoGroup, { type AppInfoRow } from '@/components/AppInfoGroup.vue'
import AppPageAtmosphere from '@/components/AppPageAtmosphere.vue'
const id = ref(0)
const info = ref<Record<string, any>>({})
const runtime = ref<any>({})
onLoad((q) => { id.value = Number(q?.id || 0) })
onShow(() => load())
async function load() {
const [rt, kl] = await Promise.all([
getAiRuntimeOptions(),
getAiKeyList({ page: 1, pageSize: 100 }),
])
runtime.value = rt || {}
const items = kl?.items || kl?.data || []
info.value = items.find((i: any) => Number(i.id) === id.value) || {}
}
const title = computed(() => info.value.name || `密钥#${id.value}`)
const isActive = computed(() => Number(runtime.value.api_key_id) === id.value)
const rows = computed<AppInfoRow[]>(() => [
{ label: '名称', value: info.value.name },
{ label: '平台', value: info.value.platform_name || info.value.provider },
{ label: '状态', value: Number(info.value.status) === 1 ? '可用' : '禁用' },
{ label: '当前启用', value: isActive.value ? '是' : '否' },
])
async function activate() {
await saveAiRuntime({
provider: info.value.provider || runtime.value.provider,
model: runtime.value.model || info.value.model || '',
api_key_id: id.value,
})
uni.showToast({ title: '已设为默认', icon: 'success' })
load()
}
function goEdit() {
uni.navigateTo({ url: `/pages-sub/system/pages/config/ai-form?id=${id.value}` })
}
function onDelete() {
uni.showModal({
title: '删除密钥',
content: `确定删除「${title.value}」吗?`,
confirmColor: '#ef4444',
success: async (res) => {
if (!res.confirm) return
await deleteAiKey([id.value])
uni.showToast({ title: '已删除', icon: 'success' })
setTimeout(() => uni.navigateBack(), 400)
},
})
}
</script>
<template>
<!-- 氛围壳提供主题作用域 + 背景色斑玻璃卡的模糊才有内容可透 -->
<AppPageAtmosphere>
<view class="nl-page">
<AppDetailHero
:title="title"
:subtitle="info.platform_name || info.provider || ''"
icon-text="AI"
:status-text="isActive ? '使用中' : '未启用'"
:status-type="isActive ? 'success' : 'default'"
/>
<AppInfoGroup title="密钥信息" :items="rows" />
<AppBottomBar
:primary-text="isActive ? '编辑' : '设为默认'"
:secondary-text="isActive ? '' : '编辑'"
danger-text="删除"
@primary="isActive ? goEdit() : activate()"
@secondary="goEdit"
@danger="onDelete"
/>
</view>
</AppPageAtmosphere>
</template>

View File

@@ -0,0 +1,102 @@
<script setup lang="ts">
/**
* AI 密钥新建(编辑仅改名称等轻量字段时走新建同页;更新接口若不足则仅新建)
*/
import { computed, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { createAiKey, getAiPlatformOption, updateAiKey } from '@/api/system'
import AppPageForm from '@/components/AppPageForm.vue'
const id = ref(0)
const submitting = ref(false)
const platforms = ref<any[]>([])
const form = ref({
platform_id: undefined as number | undefined,
name: '',
api_key: '',
})
const isEdit = computed(() => !!id.value)
onLoad(async (q) => {
id.value = Number(q?.id || 0)
const pf = await getAiPlatformOption()
platforms.value = Array.isArray(pf) ? pf : pf?.items || []
if (platforms.value[0]) form.value.platform_id = platforms.value[0].id
if (id.value) {
// 编辑页主要改名称;密钥留空表示不改(若后端支持)
form.value.name = ''
}
})
const platformLabel = computed(
() => platforms.value.find((p) => p.id === form.value.platform_id)?.name || '请选择平台',
)
function pickPlatform() {
if (!platforms.value.length) return
uni.showActionSheet({
itemList: platforms.value.map((p) => p.name || p.label || p.code),
success: (res) => {
const p = platforms.value[res.tapIndex]
if (p) form.value.platform_id = p.id
},
})
}
async function onSubmit() {
if (!form.value.name) {
uni.showToast({ title: '请填写名称', icon: 'none' })
return
}
if (!isEdit.value && !form.value.api_key) {
uni.showToast({ title: '请填写密钥', icon: 'none' })
return
}
submitting.value = true
try {
if (isEdit.value) {
await updateAiKey({ id: id.value, name: form.value.name, api_key: form.value.api_key || undefined })
} else {
await createAiKey({ ...form.value })
}
uni.showToast({ title: '保存成功', icon: 'success' })
setTimeout(() => uni.navigateBack(), 400)
} finally {
submitting.value = false
}
}
</script>
<template>
<AppPageForm tip="密钥不会回显明文,请妥善保管。" :loading="submitting" @submit="onSubmit">
<view v-if="!isEdit" class="form-item" @tap="pickPlatform">
<text class="label">平台</text>
<text class="value">{{ platformLabel }}</text>
</view>
<view class="form-item">
<text class="label">名称</text>
<input v-model="form.name" class="input" placeholder="备注名" />
</view>
<view class="form-item is-last">
<text class="label">密钥</text>
<input
v-model="form.api_key"
class="input"
password
:placeholder="isEdit ? '留空则不修改' : 'API Key'"
/>
</view>
</AppPageForm>
</template>
<style scoped lang="scss">
.form-item {
display: flex; align-items: center; justify-content: space-between;
min-height: 100rpx; border-bottom: 1rpx solid $nl-color-border;
}
.form-item.is-last { border-bottom: none; }
.label { width: 140rpx; color: $nl-color-text-secondary; font-size: 28rpx; }
.input, .value { flex: 1; text-align: right; font-size: 28rpx; }
</style>

View File

@@ -0,0 +1,90 @@
<script setup lang="ts">
/**
* AI 管理列表:运行时摘要 + 密钥卡片,点击进详情
*/
import { computed, ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { getAiKeyList, getAiRuntimeOptions } from '@/api/system'
import AppCardList, { type AppCardItem } from '@/components/AppCardList.vue'
import AppEmpty from '@/components/AppEmpty.vue'
import AppGlassCard from '@/components/AppGlassCard.vue'
import AppPageAtmosphere from '@/components/AppPageAtmosphere.vue'
import AppSkeleton from '@/components/AppSkeleton.vue'
import { usePullRefresh } from '@/composables/usePullRefresh'
const runtime = ref<any>({})
const keys = ref<any[]>([])
const loading = ref(false)
const cards = computed<AppCardItem[]>(() =>
keys.value.map((item) => ({
id: item.id,
title: item.name || `密钥#${item.id}`,
subtitle: item.platform_name || item.provider || '',
iconText: 'AI',
// nl_ai_api_key.status1=启用 0=禁用(与 PC AiKeyManage 一致,勿写反)
statusText: Number(item.status) === 1 ? '可用' : '禁用',
statusType: Number(item.status) === 1 ? 'success' : 'danger',
})),
)
onShow(() => loadAll())
usePullRefresh(loadAll)
async function loadAll() {
loading.value = true
try {
const [rt, kl] = await Promise.all([
getAiRuntimeOptions(),
getAiKeyList({ page: 1, pageSize: 50 }),
])
runtime.value = rt || {}
keys.value = kl?.items || kl?.data || []
} finally {
loading.value = false
}
}
function goDetail(item: AppCardItem) {
uni.navigateTo({ url: `/pages-sub/system/pages/config/ai-detail?id=${item.id}` })
}
function goCreate() {
uni.navigateTo({ url: '/pages-sub/system/pages/config/ai-form' })
}
</script>
<template>
<AppPageAtmosphere tone="cool">
<view class="nl-page page">
<AppGlassCard variant="tint" class="runtime" padding="28rpx">
<text class="runtime__title">当前启用</text>
<text class="runtime__line">平台 · {{ runtime.provider || runtime.platform_name || '-' }}</text>
<text class="runtime__line">模型 · {{ runtime.model || runtime.model_name || '-' }}</text>
<text class="runtime__line">密钥 · {{ runtime.api_key_name || runtime.api_key_id || '-' }}</text>
</AppGlassCard>
<view class="head">
<text class="head__title">密钥列表</text>
<text class="head__link" @tap="goCreate">新增</text>
</view>
<AppSkeleton v-if="loading && !cards.length" :rows="3" />
<AppCardList v-else-if="cards.length" :list="cards" @select="goDetail" />
<AppEmpty v-else text="暂无密钥" />
</view>
</AppPageAtmosphere>
</template>
<style scoped lang="scss">
.page { padding-bottom: 48rpx; }
.runtime { margin: 24rpx 28rpx; }
.runtime__title { display: block; font-size: 30rpx; font-weight: 700; margin-bottom: 12rpx; }
.runtime__line { display: block; font-size: 24rpx; color: $nl-color-text-secondary; line-height: 1.8; }
.head {
display: flex; justify-content: space-between; align-items: center;
/* 顶部间距由全局 .nl-page 提供,这里不再叠加 */
padding: 0 36rpx 4rpx;
}
.head__title { font-size: 28rpx; font-weight: 700; }
.head__link { color: $nl-color-primary; font-size: 26rpx; }
</style>

View File

@@ -0,0 +1,67 @@
<script setup lang="ts">
/**
* 接口详情:查看信息并切换是否记日志
*/
import { computed, ref } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
import { getApiEndpointList, updateApiEndpoint } from '@/api/system'
import AppBottomBar from '@/components/AppBottomBar.vue'
import AppDetailHero from '@/components/AppDetailHero.vue'
import AppInfoGroup, { type AppInfoRow } from '@/components/AppInfoGroup.vue'
import AppPageAtmosphere from '@/components/AppPageAtmosphere.vue'
const id = ref(0)
const info = ref<Record<string, any>>({})
onLoad((q) => { id.value = Number(q?.id || 0) })
onShow(() => load())
async function load() {
const res = await getApiEndpointList({ page: 1, pageSize: 300 })
const items = res?.items || res?.data || []
info.value = items.find((i: any) => Number(i.id) === id.value) || {}
}
const title = computed(() => info.value.title || info.value.name || '接口')
const logging = computed(() => Number(info.value.is_log) === 1)
const rows = computed<AppInfoRow[]>(() => [
{ label: '名称', value: info.value.name },
{ label: '标题', value: info.value.title },
{ label: '方法', value: info.value.method },
{ label: 'URL', value: info.value.url },
{ label: '记日志', value: logging.value ? '是' : '否' },
])
async function toggleLog() {
const next = logging.value ? 0 : 1
await updateApiEndpoint({
id: info.value.id,
is_log: next,
title: info.value.title,
status: info.value.status,
})
uni.showToast({ title: next === 1 ? '已开启日志' : '已关闭日志', icon: 'none' })
load()
}
</script>
<template>
<!-- 氛围壳提供主题作用域 + 背景色斑玻璃卡的模糊才有内容可透 -->
<AppPageAtmosphere>
<view class="nl-page">
<AppDetailHero
:title="title"
:subtitle="`${info.method || ''} ${info.url || ''}`.trim()"
icon-text=""
:status-text="logging ? '记日志' : '不记'"
:status-type="logging ? 'success' : 'default'"
/>
<AppInfoGroup title="接口信息" :items="rows" />
<AppBottomBar
:primary-text="logging ? '关闭操作日志' : '开启操作日志'"
@primary="toggleLog"
/>
</view>
</AppPageAtmosphere>
</template>

View File

@@ -0,0 +1,64 @@
<script setup lang="ts">
/**
* 接口列表:点击进详情(详情内切换是否记日志)
*/
import { computed, ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { getApiEndpointList } from '@/api/system'
import AppCardList, { type AppCardItem } from '@/components/AppCardList.vue'
import AppEmpty from '@/components/AppEmpty.vue'
import AppPageAtmosphere from '@/components/AppPageAtmosphere.vue'
import AppSearchBar from '@/components/AppSearchBar.vue'
import { usePullRefresh } from '@/composables/usePullRefresh'
const list = ref<any[]>([])
const keyword = ref('')
const loading = ref(false)
const cards = computed<AppCardItem[]>(() => {
const kw = keyword.value.trim().toLowerCase()
return list.value
.filter((item) => {
if (!kw) return true
const bag = `${item.name || ''} ${item.url || ''} ${item.title || ''}`.toLowerCase()
return bag.includes(kw)
})
.map((item) => ({
id: item.id,
title: item.title || item.name || `接口#${item.id}`,
subtitle: `${item.method || ''} ${item.url || ''}`.trim(),
iconText: '接',
statusText: Number(item.is_log) === 1 ? '记日志' : '不记',
statusType: Number(item.is_log) === 1 ? 'success' : 'default',
}))
})
onShow(() => reload())
usePullRefresh(reload)
async function reload() {
loading.value = true
try {
const res = await getApiEndpointList({ page: 1, pageSize: 200 })
list.value = res?.items || res?.data || []
} finally {
loading.value = false
}
}
function goDetail(item: AppCardItem) {
uni.navigateTo({ url: `/pages-sub/system/pages/config/api-detail?id=${item.id}` })
}
</script>
<template>
<!-- 氛围壳提供主题作用域 + 背景色斑玻璃卡的模糊才有内容可透 -->
<AppPageAtmosphere>
<view class="nl-page">
<AppSearchBar v-model="keyword" placeholder="搜索接口名/路径" />
<AppCardList v-if="cards.length" :list="cards" @select="goDetail" />
<AppEmpty v-else :text="loading ? '加载中…' : '暂无接口'" />
</view>
</AppPageAtmosphere>
</template>

View File

@@ -0,0 +1,127 @@
<script setup lang="ts">
/**
* 系统配置总览:毛玻璃设置入口卡
*/
import AppGlassCard from '@/components/AppGlassCard.vue'
import AppPageAtmosphere from '@/components/AppPageAtmosphere.vue'
const entries = [
{
key: 'ai',
title: 'AI 管理',
desc: '模型选择与 API Key',
path: '/pages-sub/system/pages/config/ai',
icon: 'secured',
},
{
key: 'oss',
title: 'OSS 管理',
desc: '存储驱动与启用配置',
path: '/pages-sub/system/pages/config/oss',
icon: 'cloud',
},
{
key: 'api',
title: '接口管理',
desc: '操作日志开关',
path: '/pages-sub/system/pages/config/api',
icon: 'link',
},
{
// 公众号账号本质是「加密配置」,配置总览里给一张入口卡(页面在 media 分包)
key: 'wechat-account',
title: '公众号账号',
desc: 'AppID 与密钥加密配置',
path: '/pages-sub/media/pages/account/list',
icon: 'chat',
},
]
function go(path: string) {
uni.navigateTo({ url: path })
}
</script>
<template>
<AppPageAtmosphere tone="cool">
<view class="nl-page page">
<text class="tip">以设置卡方式管理点卡片进入详情</text>
<AppGlassCard
v-for="item in entries"
:key="item.key"
pressable
no-padding
class="entry-gap"
@tap="go(item.path)"
>
<view class="entry">
<view class="entry__icon">
<!-- 实橙渐变底 + 白图标与通用列表图标块统一口径 -->
<wd-icon :name="item.icon" size="40rpx" color="#ffffff" />
</view>
<view class="entry__body">
<text class="entry__title">{{ item.title }}</text>
<text class="entry__desc">{{ item.desc }}</text>
</view>
<wd-icon name="arrow-right" size="32rpx" color="#6b7280" />
</view>
</AppGlassCard>
</view>
</AppPageAtmosphere>
</template>
<style scoped lang="scss">
.page {
/* 顶部 24rpx 与全局 .nl-page 标准一致(同元素上本规则优先级更高,需显式写) */
padding: 24rpx 28rpx 28rpx;
}
.tip {
display: block;
margin: 0 8rpx 20rpx;
font-size: 24rpx;
color: $nl-color-text-muted;
}
.entry-gap {
margin-bottom: 24rpx;
}
.entry {
display: flex;
align-items: center;
min-height: 140rpx;
padding: 24rpx;
}
.entry__icon {
width: 88rpx;
height: 88rpx;
border-radius: 26rpx;
background: $nl-gradient-primary;
box-shadow: 0 8rpx 20rpx rgba(255, 107, 0, 0.25);
display: flex;
align-items: center;
justify-content: center;
margin-right: 20rpx;
}
.entry__body {
flex: 1;
min-width: 0;
}
.entry__title {
display: block;
font-size: 32rpx;
font-weight: 700;
}
.entry__desc {
display: block;
margin-top: 8rpx;
font-size: 24rpx;
/* 小字号说明用 secondary 保证对比度 */
color: $nl-color-text-secondary;
}
</style>

View File

@@ -0,0 +1,88 @@
<script setup lang="ts">
/**
* OSS 配置详情:启用 / 编辑 / 删除
*/
import { computed, ref } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
import { deleteOssConfig, getOssConfigList, getOssRuntimeOptions, saveOssRuntime } from '@/api/system'
import AppBottomBar from '@/components/AppBottomBar.vue'
import AppDetailHero from '@/components/AppDetailHero.vue'
import AppInfoGroup, { type AppInfoRow } from '@/components/AppInfoGroup.vue'
import AppPageAtmosphere from '@/components/AppPageAtmosphere.vue'
const id = ref(0)
const info = ref<Record<string, any>>({})
const activeId = ref(0)
onLoad((q) => { id.value = Number(q?.id || 0) })
onShow(() => load())
async function load() {
const [rt, ls] = await Promise.all([
getOssRuntimeOptions(),
getOssConfigList({ page: 1, pageSize: 100 }),
])
activeId.value = Number(rt?.active_id || rt?.id || 0)
const items = ls?.items || ls?.data || []
info.value = items.find((i: any) => Number(i.id) === id.value) || {}
}
const title = computed(() => info.value.name || `配置#${id.value}`)
const isActive = computed(() => Number(activeId.value) === id.value)
const rows = computed<AppInfoRow[]>(() => [
{ label: '名称', value: info.value.name },
{ label: '驱动', value: info.value.driver },
{ label: 'Bucket', value: info.value.bucket },
{ label: '域名', value: info.value.domain },
{ label: 'AccessKey', value: info.value.has_access_key ? '已配置' : '未配置' },
])
async function activate() {
await saveOssRuntime({ id: id.value })
uni.showToast({ title: '已启用', icon: 'success' })
load()
}
function goEdit() {
uni.navigateTo({ url: `/pages-sub/system/pages/config/oss-form?id=${id.value}` })
}
function onDelete() {
uni.showModal({
title: '删除配置',
content: `确定删除「${title.value}」吗?`,
confirmColor: '#ef4444',
success: async (res) => {
if (!res.confirm) return
await deleteOssConfig([id.value])
uni.showToast({ title: '已删除', icon: 'success' })
setTimeout(() => uni.navigateBack(), 400)
},
})
}
</script>
<template>
<!-- 氛围壳提供主题作用域 + 背景色斑玻璃卡的模糊才有内容可透 -->
<AppPageAtmosphere>
<view class="nl-page">
<AppDetailHero
:title="title"
:subtitle="info.driver || ''"
icon-text=""
:status-text="isActive ? '使用中' : '未启用'"
:status-type="isActive ? 'success' : 'default'"
/>
<AppInfoGroup title="配置信息" :items="rows" />
<AppBottomBar
:primary-text="isActive ? '编辑' : '启用此配置'"
:secondary-text="isActive ? '' : '编辑'"
danger-text="删除"
@primary="isActive ? goEdit() : activate()"
@secondary="goEdit"
@danger="onDelete"
/>
</view>
</AppPageAtmosphere>
</template>

View File

@@ -0,0 +1,119 @@
<script setup lang="ts">
/**
* OSS 新建/编辑全屏表单
*/
import { computed, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import {
createOssConfig,
getOssConfigList,
getOssDriverOptions,
updateOssConfig,
} from '@/api/system'
import AppPageForm from '@/components/AppPageForm.vue'
const id = ref(0)
const submitting = ref(false)
const drivers = ref<any[]>([])
const form = ref({
name: '',
driver: 'local',
bucket: '',
domain: '',
access_key: '',
secret_key: '',
})
const isEdit = computed(() => !!id.value)
onLoad(async (q) => {
id.value = Number(q?.id || 0)
const dr = await getOssDriverOptions()
drivers.value = Array.isArray(dr) ? dr : dr?.items || []
if (drivers.value[0]) {
form.value.driver = drivers.value[0].value || drivers.value[0].code || 'local'
}
if (id.value) {
const ls = await getOssConfigList({ page: 1, pageSize: 100 })
const items = ls?.items || ls?.data || []
const info = items.find((i: any) => Number(i.id) === id.value)
if (info) {
form.value = {
name: info.name || '',
driver: info.driver || 'local',
bucket: info.bucket || '',
domain: info.domain || '',
access_key: '',
secret_key: '',
}
}
}
})
function pickDriver() {
if (!drivers.value.length) return
uni.showActionSheet({
itemList: drivers.value.map((d) => d.label || d.name || d.value || d.code),
success: (res) => {
const d = drivers.value[res.tapIndex]
form.value.driver = d.value || d.code || d.name
},
})
}
async function onSubmit() {
if (!form.value.name) {
uni.showToast({ title: '请填写名称', icon: 'none' })
return
}
submitting.value = true
try {
if (isEdit.value) await updateOssConfig({ id: id.value, ...form.value })
else await createOssConfig({ ...form.value })
uni.showToast({ title: '保存成功', icon: 'success' })
setTimeout(() => uni.navigateBack(), 400)
} finally {
submitting.value = false
}
}
</script>
<template>
<AppPageForm tip="密钥留空表示不修改已有密钥。" :loading="submitting" @submit="onSubmit">
<view class="form-item">
<text class="label">名称</text>
<input v-model="form.name" class="input" placeholder="配置名" />
</view>
<view class="form-item" @tap="pickDriver">
<text class="label">驱动</text>
<text class="value">{{ form.driver }}</text>
</view>
<view class="form-item">
<text class="label">Bucket</text>
<input v-model="form.bucket" class="input" />
</view>
<view class="form-item">
<text class="label">域名</text>
<input v-model="form.domain" class="input" />
</view>
<view class="form-item">
<text class="label">AccessKey</text>
<input v-model="form.access_key" class="input" password placeholder="可选" />
</view>
<view class="form-item is-last">
<text class="label">SecretKey</text>
<input v-model="form.secret_key" class="input" password placeholder="可选" />
</view>
</AppPageForm>
</template>
<style scoped lang="scss">
.form-item {
display: flex; align-items: center; justify-content: space-between;
min-height: 100rpx; border-bottom: 1rpx solid $nl-color-border;
}
.form-item.is-last { border-bottom: none; }
.label { width: 160rpx; color: $nl-color-text-secondary; font-size: 28rpx; }
.input, .value { flex: 1; text-align: right; font-size: 28rpx; }
</style>

View File

@@ -0,0 +1,77 @@
<script setup lang="ts">
/**
* OSS 配置列表:点击进详情
*/
import { computed, ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { getOssConfigList, getOssRuntimeOptions } from '@/api/system'
import AppCardList, { type AppCardItem } from '@/components/AppCardList.vue'
import AppEmpty from '@/components/AppEmpty.vue'
import AppPageAtmosphere from '@/components/AppPageAtmosphere.vue'
import { usePullRefresh } from '@/composables/usePullRefresh'
const activeId = ref(0)
const list = ref<any[]>([])
const loading = ref(false)
const cards = computed<AppCardItem[]>(() =>
list.value.map((item) => ({
id: item.id,
title: item.name || `配置#${item.id}`,
subtitle: `${item.driver || ''}${item.bucket ? ' · ' + item.bucket : ''}`,
iconText: '云',
statusText: Number(item.id) === activeId.value ? '使用中' : '未启用',
statusType: Number(item.id) === activeId.value ? 'success' : 'default',
})),
)
onShow(() => loadAll())
usePullRefresh(loadAll)
async function loadAll() {
loading.value = true
try {
const [rt, ls] = await Promise.all([
getOssRuntimeOptions(),
getOssConfigList({ page: 1, pageSize: 50 }),
])
activeId.value = Number(rt?.active_id || rt?.id || 0)
list.value = ls?.items || ls?.data || rt?.items || []
} finally {
loading.value = false
}
}
function goDetail(item: AppCardItem) {
uni.navigateTo({ url: `/pages-sub/system/pages/config/oss-detail?id=${item.id}` })
}
function goCreate() {
uni.navigateTo({ url: '/pages-sub/system/pages/config/oss-form' })
}
</script>
<template>
<!-- 氛围壳提供主题作用域 + 背景色斑玻璃卡的模糊才有内容可透 -->
<AppPageAtmosphere>
<view class="nl-page page">
<view class="head">
<text class="head__title">存储配置</text>
<text class="head__link" @tap="goCreate">新增</text>
</view>
<AppCardList v-if="cards.length" :list="cards" @select="goDetail" />
<AppEmpty v-else :text="loading ? '加载中…' : '暂无配置'" />
</view>
</AppPageAtmosphere>
</template>
<style scoped lang="scss">
.head {
display: flex; justify-content: space-between; align-items: center;
/* 顶部间距由全局 .nl-page 提供,这里不再叠加 */
padding: 0 36rpx 8rpx;
}
.head__title { font-size: 30rpx; font-weight: 700; }
.head__link { color: $nl-color-primary; font-size: 26rpx; }
</style>

View File

@@ -0,0 +1,52 @@
<script setup lang="ts">
/**
* 菜单详情
*/
import { computed, ref } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
import { getMenuInfo } from '@/api/menu'
import AppBottomBar from '@/components/AppBottomBar.vue'
import AppDetailHero from '@/components/AppDetailHero.vue'
import AppInfoGroup, { type AppInfoRow } from '@/components/AppInfoGroup.vue'
import AppPageAtmosphere from '@/components/AppPageAtmosphere.vue'
const id = ref(0)
const info = ref<Record<string, any>>({})
onLoad((q) => { id.value = Number(q?.id || 0) })
onShow(async () => {
if (id.value) info.value = (await getMenuInfo(id.value)) || {}
})
const title = computed(() => info.value.title || info.value.name || '菜单')
const rows = computed<AppInfoRow[]>(() => [
{ label: '标题', value: info.value.title },
{ label: 'Name', value: info.value.name },
{ label: '路径', value: info.value.path },
{ label: '组件', value: info.value.component },
{ label: '排序', value: info.value.sort },
{ label: '显示', value: Number(info.value.hide_in_menu) === 0 ? '显示' : '隐藏' },
])
function goEdit() {
uni.navigateTo({ url: `/pages-sub/system/pages/menu/form?id=${id.value}` })
}
</script>
<template>
<!-- 氛围壳提供主题作用域 + 背景色斑玻璃卡的模糊才有内容可透 -->
<AppPageAtmosphere>
<view class="nl-page">
<AppDetailHero
:title="title"
:subtitle="info.path || ''"
icon-text=""
:status-text="Number(info.hide_in_menu) === 0 ? '显示' : '隐藏'"
:status-type="Number(info.hide_in_menu) === 0 ? 'success' : 'warning'"
/>
<AppInfoGroup title="菜单信息" :items="rows" />
<AppBottomBar primary-text="编辑菜单" @primary="goEdit" />
</view>
</AppPageAtmosphere>
</template>

View File

@@ -0,0 +1,90 @@
<script setup lang="ts">
/**
* 菜单编辑(移动端轻量字段)
*/
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { getMenuInfo, updateMenu } from '@/api/menu'
import AppPageForm from '@/components/AppPageForm.vue'
const id = ref(0)
const submitting = ref(false)
// 后端 MenuController.updateField 里 pid/icon/affix_tab/keep_alive 属必填(不在 notRequest
// 移动端只做常用字段快改,但必须把这些必填字段原样带回,否则保存必报「未填写的字段」
const form = ref({
title: '',
name: '',
path: '',
sort: 0 as number | string,
hide_in_menu: 0,
pid: 0,
icon: '',
affix_tab: 1,
keep_alive: 1,
})
onLoad(async (q) => {
id.value = Number(q?.id || 0)
if (!id.value) return
const info = await getMenuInfo(id.value)
form.value = {
title: info?.title || '',
name: info?.name || '',
path: info?.path || '',
sort: info?.sort ?? 0,
hide_in_menu: Number(info?.hide_in_menu || 0),
// 以下字段不在移动端表单展示,仅透传保证后端必填校验通过
pid: Number(info?.pid ?? 0),
icon: info?.icon || '',
affix_tab: Number(info?.affix_tab ?? 1),
keep_alive: Number(info?.keep_alive ?? 1),
}
})
async function onSubmit() {
submitting.value = true
try {
await updateMenu({ id: id.value, ...form.value })
uni.showToast({ title: '保存成功', icon: 'success' })
setTimeout(() => uni.navigateBack(), 400)
} finally {
submitting.value = false
}
}
</script>
<template>
<AppPageForm tip="复杂菜单树建议在 PC 端维护,这里提供常用字段快改。" :loading="submitting" @submit="onSubmit">
<view class="form-item">
<text class="label">标题</text>
<input v-model="form.title" class="input" />
</view>
<view class="form-item">
<text class="label">Name</text>
<input v-model="form.name" class="input" />
</view>
<view class="form-item">
<text class="label">路径</text>
<input v-model="form.path" class="input" />
</view>
<view class="form-item">
<text class="label">排序</text>
<input v-model="form.sort" class="input" type="number" />
</view>
<view class="form-item is-last" @tap="form.hide_in_menu = form.hide_in_menu === 0 ? 1 : 0">
<text class="label">菜单显示</text>
<text class="value">{{ form.hide_in_menu === 0 ? '显示' : '隐藏' }}</text>
</view>
</AppPageForm>
</template>
<style scoped lang="scss">
.form-item {
display: flex; align-items: center; justify-content: space-between;
min-height: 100rpx; border-bottom: 1rpx solid $nl-color-border;
}
.form-item.is-last { border-bottom: none; }
.label { width: 140rpx; color: $nl-color-text-secondary; font-size: 28rpx; }
.input, .value { flex: 1; text-align: right; font-size: 28rpx; }
</style>

View File

@@ -0,0 +1,70 @@
<script setup lang="ts">
/**
* 菜单列表:点击进详情(移动端轻量维护)
*/
import { computed, ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { getMenuList } from '@/api/menu'
import AppCardList, { type AppCardItem } from '@/components/AppCardList.vue'
import AppEmpty from '@/components/AppEmpty.vue'
import AppPageAtmosphere from '@/components/AppPageAtmosphere.vue'
import AppSearchBar from '@/components/AppSearchBar.vue'
import AppSkeleton from '@/components/AppSkeleton.vue'
import { usePullRefresh } from '@/composables/usePullRefresh'
const list = ref<any[]>([])
const keyword = ref('')
const loading = ref(false)
function flattenMenus(nodes: any[], acc: any[] = []): any[] {
nodes.forEach((n) => {
acc.push(n)
if (Array.isArray(n.children) && n.children.length) flattenMenus(n.children, acc)
})
return acc
}
const cards = computed<AppCardItem[]>(() => {
const kw = keyword.value.trim()
return list.value
.filter((item) => !kw || String(item.title || '').includes(kw) || String(item.name || '').includes(kw))
.map((item) => ({
id: item.id,
title: item.title || item.name || `菜单#${item.id}`,
subtitle: item.path || item.component || '',
iconText: '菜',
statusText: Number(item.hide_in_menu) === 1 ? '隐藏' : '显示',
statusType: Number(item.hide_in_menu) === 1 ? 'warning' : 'success',
}))
})
onShow(() => reload())
usePullRefresh(reload)
async function reload() {
loading.value = true
try {
const res = await getMenuList({ page: 1, pageSize: 500 })
const items = res?.items || res?.data || res || []
list.value = Array.isArray(items) ? flattenMenus(items) : []
} finally {
loading.value = false
}
}
function goDetail(item: AppCardItem) {
uni.navigateTo({ url: `/pages-sub/system/pages/menu/detail?id=${item.id}` })
}
</script>
<template>
<AppPageAtmosphere tone="cool">
<view class="nl-page">
<AppSearchBar v-model="keyword" placeholder="搜索菜单标题" />
<AppSkeleton v-if="loading && !cards.length" />
<AppCardList v-else-if="cards.length" :list="cards" @select="goDetail" />
<AppEmpty v-else text="暂无菜单" />
</view>
</AppPageAtmosphere>
</template>

View File

@@ -0,0 +1,66 @@
<script setup lang="ts">
/**
* 角色详情
*/
import { computed, ref } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
import { deleteRole, getRoleInfo } from '@/api/role'
import AppBottomBar from '@/components/AppBottomBar.vue'
import AppDetailHero from '@/components/AppDetailHero.vue'
import AppInfoGroup, { type AppInfoRow } from '@/components/AppInfoGroup.vue'
import AppPageAtmosphere from '@/components/AppPageAtmosphere.vue'
const id = ref(0)
const info = ref<Record<string, any>>({})
onLoad((q) => { id.value = Number(q?.id || 0) })
onShow(() => { if (id.value) load() })
async function load() {
info.value = (await getRoleInfo(id.value)) || {}
}
const title = computed(() => info.value.name || '角色')
// nl_role 表没有 status 列,不展示状态行(原来恒显示「禁用」是误导)
const rows = computed<AppInfoRow[]>(() => [
{ label: '名称', value: info.value.name },
{ label: '标识', value: info.value.value },
{ label: '备注', value: info.value.desc },
{ label: '创建时间', value: info.value.created_at },
])
function goEdit() {
uni.navigateTo({ url: `/pages-sub/system/pages/role/form?id=${id.value}` })
}
function onDelete() {
uni.showModal({
title: '删除角色',
content: `确定删除「${title.value}」吗?`,
confirmColor: '#ef4444',
success: async (res) => {
if (!res.confirm) return
await deleteRole({ ids: [id.value] })
uni.showToast({ title: '已删除', icon: 'success' })
setTimeout(() => uni.navigateBack(), 400)
},
})
}
</script>
<template>
<!-- 氛围壳提供主题作用域 + 背景色斑玻璃卡的模糊才有内容可透 -->
<AppPageAtmosphere>
<view class="nl-page">
<!-- nl_role status 不传状态徽标原来恒显示禁用是误导 -->
<AppDetailHero
:title="title"
:subtitle="info.value || ''"
icon-text=""
/>
<AppInfoGroup title="角色信息" :items="rows" />
<AppBottomBar primary-text="编辑角色" danger-text="删除" @primary="goEdit" @danger="onDelete" />
</view>
</AppPageAtmosphere>
</template>

View File

@@ -0,0 +1,72 @@
<script setup lang="ts">
/**
* 角色新建/编辑
*/
import { computed, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { createRole, getRoleInfo, updateRole } from '@/api/role'
import AppPageForm from '@/components/AppPageForm.vue'
const id = ref(0)
const submitting = ref(false)
// 字段名严格对齐后端 RoleControllerinsertField/updateField = name,value,descdesc 必填)
// 注意nl_role 无 status 列,之前的 remark/status 会导致创建时报「未填写的字段: desc」
const form = ref({ name: '', value: '', desc: '' })
const isEdit = computed(() => !!id.value)
onLoad(async (q) => {
id.value = Number(q?.id || 0)
if (id.value) {
const info = await getRoleInfo(id.value)
form.value = {
name: info?.name || '',
value: info?.value || '',
desc: info?.desc || '',
}
}
})
async function onSubmit() {
if (!form.value.name) {
uni.showToast({ title: '请填写名称', icon: 'none' })
return
}
submitting.value = true
try {
if (isEdit.value) await updateRole({ id: id.value, ...form.value })
else await createRole({ ...form.value })
uni.showToast({ title: '保存成功', icon: 'success' })
setTimeout(() => uni.navigateBack(), 400)
} finally {
submitting.value = false
}
}
</script>
<template>
<AppPageForm tip="角色标识建议用英文,便于权限判断。" :loading="submitting" @submit="onSubmit">
<view class="form-item">
<text class="label">名称</text>
<input v-model="form.name" class="input" placeholder="角色名称" />
</view>
<view class="form-item">
<text class="label">标识</text>
<input v-model="form.value" class="input" placeholder="如 admin" />
</view>
<view class="form-item is-last">
<text class="label">备注</text>
<input v-model="form.desc" class="input" placeholder="角色描述(必填)" />
</view>
</AppPageForm>
</template>
<style scoped lang="scss">
.form-item {
display: flex; align-items: center; justify-content: space-between;
min-height: 100rpx; border-bottom: 1rpx solid $nl-color-border;
}
.form-item.is-last { border-bottom: none; }
.label { width: 140rpx; color: $nl-color-text-secondary; font-size: 28rpx; }
.input, .value { flex: 1; text-align: right; font-size: 28rpx; }
</style>

View File

@@ -0,0 +1,72 @@
<script setup lang="ts">
/**
* 角色列表:点击进详情
*/
import { computed, ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { getRoleList } from '@/api/role'
import AppCardList, { type AppCardItem } from '@/components/AppCardList.vue'
import AppEmpty from '@/components/AppEmpty.vue'
import AppFab from '@/components/AppFab.vue'
import AppPageAtmosphere from '@/components/AppPageAtmosphere.vue'
import AppSearchBar from '@/components/AppSearchBar.vue'
import AppSkeleton from '@/components/AppSkeleton.vue'
import { usePullRefresh } from '@/composables/usePullRefresh'
const list = ref<any[]>([])
const keyword = ref('')
const loading = ref(false)
const cards = computed<AppCardItem[]>(() =>
list.value.map((item) => ({
id: item.id,
title: item.name || `角色#${item.id}`,
subtitle: item.value || item.desc || '',
iconText: '角',
// nl_role 无 status 列,不展示状态徽标(原来 Number(undefined) 恒走「禁用」,全部角色误标红)
})),
)
onShow(() => reload())
usePullRefresh(reload)
async function reload() {
loading.value = true
try {
const res = await getRoleList({ page: 1, pageSize: 100, name: keyword.value || undefined })
list.value = res?.items || res?.data || []
} finally {
loading.value = false
}
}
function goDetail(item: AppCardItem) {
uni.navigateTo({ url: `/pages-sub/system/pages/role/detail?id=${item.id}` })
}
function goCreate() {
uni.navigateTo({ url: '/pages-sub/system/pages/role/form' })
}
</script>
<template>
<AppPageAtmosphere tone="cool">
<view class="nl-page page">
<AppSearchBar
v-model="keyword"
placeholder="搜索角色"
@search="reload"
@clear="() => { keyword = ''; reload() }"
/>
<AppSkeleton v-if="loading && !cards.length" />
<AppCardList v-else-if="cards.length" :list="cards" @select="goDetail" />
<AppEmpty v-else text="暂无角色" />
<AppFab @tap="goCreate" />
</view>
</AppPageAtmosphere>
</template>
<style scoped lang="scss">
.page { padding-bottom: 160rpx; }
</style>

View File

@@ -1,14 +1,36 @@
{
"pages": [
{
"path": "pages/index",
"type": "home"
"path": "pages/workbench/index",
"style": {
"navigationBarTitleText": "工作台",
"navigationStyle": "custom",
"enablePullDownRefresh": true,
"backgroundTextStyle": "dark"
}
},
{
"path": "pages/feature/index",
"style": {
"navigationBarTitleText": "功能",
"navigationStyle": "custom",
"enablePullDownRefresh": false
}
},
{
"path": "pages/mine/mine",
"style": {
"navigationBarTitleText": "我的",
"enablePullDownRefresh": false
"navigationStyle": "custom",
"enablePullDownRefresh": true,
"backgroundTextStyle": "dark"
}
},
{
"path": "pages/login/login",
"style": {
"navigationBarTitleText": "登录",
"navigationStyle": "custom"
}
}
],
@@ -17,10 +39,164 @@
"backgroundColorBottom": "@bgColorBottom",
"backgroundColorTop": "@bgColorTop",
"backgroundTextStyle": "@bgTxtStyle",
"navigationBarBackgroundColor": "#000000",
"navigationBarBackgroundColor": "@navBgColor",
"navigationBarTextStyle": "@navTxtStyle",
"navigationBarTitleText": "Uni Creator",
"navigationStyle": "custom"
"navigationBarTitleText": "NL Admin",
"navigationStyle": "default"
},
"subPackages": []
"tabBar": {
"custom": true,
"color": "@tabFontColor",
"selectedColor": "@tabSelectedColor",
"backgroundColor": "@tabBgColor",
"borderStyle": "@tabBorderStyle",
"list": [
{
"pagePath": "pages/workbench/index",
"text": "工作台",
"iconPath": "static/tabbar/workbench.png",
"selectedIconPath": "static/tabbar/workbench-active.png"
},
{
"pagePath": "pages/feature/index",
"text": "功能",
"iconPath": "static/tabbar/feature.png",
"selectedIconPath": "static/tabbar/feature-active.png"
},
{
"pagePath": "pages/mine/mine",
"text": "我的",
"iconPath": "static/tabbar/mine.png",
"selectedIconPath": "static/tabbar/mine-active.png"
}
]
},
"subPackages": [
{
"root": "pages-sub/system",
"name": "system",
"pages": [
{
"path": "pages/admin/list",
"style": {
"navigationBarTitleText": "管理员",
"enablePullDownRefresh": true,
"backgroundTextStyle": "dark"
}
},
{ "path": "pages/admin/detail", "style": { "navigationBarTitleText": "管理员详情" } },
{ "path": "pages/admin/form", "style": { "navigationBarTitleText": "编辑管理员" } },
{
"path": "pages/role/list",
"style": {
"navigationBarTitleText": "角色管理",
"enablePullDownRefresh": true,
"backgroundTextStyle": "dark"
}
},
{ "path": "pages/role/detail", "style": { "navigationBarTitleText": "角色详情" } },
{ "path": "pages/role/form", "style": { "navigationBarTitleText": "编辑角色" } },
{
"path": "pages/menu/list",
"style": {
"navigationBarTitleText": "菜单管理",
"enablePullDownRefresh": true,
"backgroundTextStyle": "dark"
}
},
{ "path": "pages/menu/detail", "style": { "navigationBarTitleText": "菜单详情" } },
{ "path": "pages/menu/form", "style": { "navigationBarTitleText": "编辑菜单" } },
{ "path": "pages/config/index", "style": { "navigationBarTitleText": "系统配置" } },
{
"path": "pages/config/ai",
"style": {
"navigationBarTitleText": "AI 管理",
"enablePullDownRefresh": true,
"backgroundTextStyle": "dark"
}
},
{ "path": "pages/config/ai-detail", "style": { "navigationBarTitleText": "密钥详情" } },
{ "path": "pages/config/ai-form", "style": { "navigationBarTitleText": "编辑密钥" } },
{
"path": "pages/config/oss",
"style": {
"navigationBarTitleText": "OSS 管理",
"enablePullDownRefresh": true,
"backgroundTextStyle": "dark"
}
},
{ "path": "pages/config/oss-detail", "style": { "navigationBarTitleText": "存储详情" } },
{ "path": "pages/config/oss-form", "style": { "navigationBarTitleText": "编辑存储" } },
{
"path": "pages/config/api",
"style": {
"navigationBarTitleText": "接口管理",
"enablePullDownRefresh": true,
"backgroundTextStyle": "dark"
}
},
{ "path": "pages/config/api-detail", "style": { "navigationBarTitleText": "接口详情" } }
]
},
{
"root": "pages-sub/mine",
"name": "mine-sub",
"pages": [
{ "path": "pages/profile", "style": { "navigationBarTitleText": "编辑资料" } },
{
"path": "pages/login-log",
"style": {
"navigationBarTitleText": "登录日志",
"enablePullDownRefresh": true,
"backgroundTextStyle": "dark"
}
},
{
"path": "pages/op-log",
"style": {
"navigationBarTitleText": "操作日志",
"enablePullDownRefresh": true,
"backgroundTextStyle": "dark"
}
}
]
},
{
"root": "pages-sub/media",
"name": "media",
"pages": [
{
"path": "pages/article/list",
"style": {
"navigationBarTitleText": "图文创作",
"enablePullDownRefresh": true,
"backgroundTextStyle": "dark"
}
},
{ "path": "pages/article/detail", "style": { "navigationBarTitleText": "图文详情" } },
{ "path": "pages/article/form", "style": { "navigationBarTitleText": "图文编辑" } },
{
"path": "pages/article/versions",
"style": {
"navigationBarTitleText": "版本记录",
"enablePullDownRefresh": true,
"backgroundTextStyle": "dark"
}
}
]
},
{
"root": "pages-sub/my-gen",
"name": "my-gen",
"pages": [
{ "path": "pages/readme", "style": { "navigationBarTitleText": "代码生成模块" } }
]
}
],
"preloadRule": {
"pages/feature/index": {
"network": "wifi",
"packages": ["system"]
}
}
}

284
src/pages/feature/index.vue Normal file
View File

@@ -0,0 +1,284 @@
<script setup lang="ts">
/**
* 功能中心:分类滑动 + 轻筛选 + 大留白入口卡
*/
import { computed, ref, watch } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import AppCategoryTabs from '@/components/AppCategoryTabs.vue'
import AppEmpty from '@/components/AppEmpty.vue'
import AppFeatureList from '@/components/AppFeatureList.vue'
import AppFilterBar from '@/components/AppFilterBar.vue'
import AppPageAtmosphere from '@/components/AppPageAtmosphere.vue'
import AppSearchBar from '@/components/AppSearchBar.vue'
import AppTabBar from '@/components/AppTabBar.vue'
import {
FEATURE_CATEGORIES,
filterFeatures,
type FeatureCategory,
type FeatureItem,
} from '@/config/features'
import { useUserStore } from '@/stores/user'
import { FEATURE_FILTER_STORAGE_KEY, FEATURE_TAB_STORAGE_KEY } from '@/utils/env'
import { getRecentFeatureKeys, pushRecentFeature } from '@/utils/recent'
const userStore = useUserStore()
const category = ref<FeatureCategory>('all')
const swiperCurrent = ref(0)
const keyword = ref('')
const draftKeyword = ref('')
const recentOnly = ref(false)
const draftRecentOnly = ref(false)
/** 内层 scroll-view 下拉刷新态(功能页有 swiper页面级下拉会失效 */
const refreshing = ref(false)
const refreshTick = ref(0)
const categoryItems = FEATURE_CATEGORIES.map((c) => ({ key: c.key, label: c.label }))
const selectedCount = computed(() => {
let n = 0
if (keyword.value) n += 1
if (recentOnly.value) n += 1
return n
})
const panels = computed(() => {
// 依赖 refreshTick下拉后强制重算最近使用
void refreshTick.value
const recentKeys = getRecentFeatureKeys()
return FEATURE_CATEGORIES.map((c) => ({
key: c.key,
list: filterFeatures({
category: c.key,
keyword: keyword.value,
recentOnly: recentOnly.value,
recentKeys,
}),
}))
})
/** scroll-view 下拉刷新:重读筛选与最近使用 */
async function onRefresherRefresh() {
if (refreshing.value) return
refreshing.value = true
try {
restoreFilter()
refreshTick.value += 1
await new Promise((r) => setTimeout(r, 280))
} finally {
refreshing.value = false
}
}
onShow(() => {
uni.hideTabBar({ animation: false })
userStore.hydrateFromStorage()
if (!userStore.isLogin) {
uni.reLaunch({ url: '/pages/login/login' })
return
}
restoreFilter()
syncSwiperFromCategory()
})
function restoreFilter() {
try {
const raw = uni.getStorageSync(FEATURE_FILTER_STORAGE_KEY)
const data = typeof raw === 'string' ? JSON.parse(raw || '{}') : raw || {}
keyword.value = data.keyword || ''
draftKeyword.value = keyword.value
recentOnly.value = !!data.recentOnly
draftRecentOnly.value = recentOnly.value
} catch {
// ignore
}
}
function persistFilter() {
uni.setStorageSync(
FEATURE_FILTER_STORAGE_KEY,
JSON.stringify({ keyword: keyword.value, recentOnly: recentOnly.value }),
)
}
function syncSwiperFromCategory() {
const idx = FEATURE_CATEGORIES.findIndex((c) => c.key === category.value)
swiperCurrent.value = idx >= 0 ? idx : 0
}
watch(category, () => syncSwiperFromCategory())
function onCategoryChange(key: string) {
category.value = key as FeatureCategory
uni.setStorageSync(FEATURE_TAB_STORAGE_KEY, key)
}
function onSwiperChange(e: any) {
const idx = Number(e?.detail?.current ?? 0)
swiperCurrent.value = idx
const item = FEATURE_CATEGORIES[idx]
if (!item) return
category.value = item.key
uni.setStorageSync(FEATURE_TAB_STORAGE_KEY, item.key)
}
function onSearch(v: string) {
draftKeyword.value = v
keyword.value = v
persistFilter()
}
function onFilterReset() {
draftKeyword.value = ''
draftRecentOnly.value = false
}
function onFilterConfirm() {
keyword.value = draftKeyword.value
recentOnly.value = draftRecentOnly.value
persistFilter()
}
function onSelect(item: FeatureItem) {
pushRecentFeature(item.key)
uni.navigateTo({ url: item.path })
}
function statusBarHeight() {
try {
return uni.getSystemInfoSync().statusBarHeight || 20
} catch {
return 20
}
}
const padTop = `${statusBarHeight() + 8}px`
</script>
<template>
<AppPageAtmosphere>
<view class="nl-page feature" :style="{ paddingTop: padTop }">
<view class="feature__title">全部功能</view>
<text class="feature__sub">左右滑动切换分类点卡片进入应用</text>
<AppSearchBar
v-model="keyword"
placeholder="搜索功能"
@search="onSearch"
@clear="onSearch('')"
/>
<AppCategoryTabs
v-model="category"
:items="categoryItems"
:storage-key="FEATURE_TAB_STORAGE_KEY"
@change="onCategoryChange"
/>
<AppFilterBar :selected-count="selectedCount" @reset="onFilterReset" @confirm="onFilterConfirm">
<view class="filter-fields">
<view class="filter-fields__row">
<text class="filter-fields__label">关键词</text>
<input
class="filter-fields__input"
:value="draftKeyword"
placeholder="标题/描述"
@input="(e: any) => (draftKeyword = e.detail.value)"
/>
</view>
<view class="filter-fields__row" @tap="draftRecentOnly = !draftRecentOnly">
<text class="filter-fields__label">仅最近使用</text>
<view class="filter-fields__switch" :class="{ 'is-on': draftRecentOnly }">
{{ draftRecentOnly ? '开' : '关' }}
</view>
</view>
</view>
</AppFilterBar>
<swiper class="feature__swiper" :current="swiperCurrent" @change="onSwiperChange">
<swiper-item v-for="panel in panels" :key="panel.key">
<scroll-view
class="feature__scroll"
scroll-y
refresher-enabled
:refresher-triggered="refreshing"
refresher-default-style="black"
refresher-background="transparent"
@refresherrefresh="onRefresherRefresh"
>
<AppFeatureList v-if="panel.list.length" :list="panel.list" @select="onSelect" />
<AppEmpty v-else text="该分类下暂无功能" />
</scroll-view>
</swiper-item>
</swiper>
<AppTabBar model-value="feature" />
</view>
</AppPageAtmosphere>
</template>
<style scoped lang="scss">
.feature {
height: 100vh;
display: flex;
flex-direction: column;
box-sizing: border-box;
/* 底部 160rpx悬浮 TabBar 占 128rpx高 108 + 底距 20+ 呼吸空间 */
padding-bottom: calc(160rpx + env(safe-area-inset-bottom));
}
.feature__title {
padding: 8rpx 32rpx 0;
font-size: 44rpx;
font-weight: 800;
}
.feature__sub {
display: block;
padding: 8rpx 32rpx 12rpx;
font-size: 24rpx;
color: $nl-color-text-muted;
}
.feature__swiper {
flex: 1;
min-height: 0;
}
.feature__scroll {
height: 100%;
}
.filter-fields__row {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 80rpx;
margin-bottom: 8rpx;
}
.filter-fields__label {
font-size: 26rpx;
color: $nl-color-text-secondary;
}
.filter-fields__input {
flex: 1;
margin-left: 24rpx;
text-align: right;
font-size: 26rpx;
}
.filter-fields__switch {
min-width: 72rpx;
height: 48rpx;
border-radius: 999rpx;
background: $nl-color-bg-page;
color: $nl-color-text-muted;
font-size: 22rpx;
display: flex;
align-items: center;
justify-content: center;
}
.filter-fields__switch.is-on {
background: $nl-color-primary-soft;
color: $nl-color-primary;
}
</style>

View File

@@ -1,20 +0,0 @@
<script setup lang="ts">
import AppFooter from '@/components/AppFooter.vue'
import AppLogos from '@/components/AppLogos.vue'
import InputEntry from '@/components/InputEntry.vue'
</script>
<template>
<view class="root-container">
<AppLogos />
<InputEntry />
<AppFooter />
</view>
</template>
<style scoped>
.root-container {
padding: 5rem 2.5rem;
text-align: center;
}
</style>

153
src/pages/login/login.vue Normal file
View File

@@ -0,0 +1,153 @@
<script setup lang="ts">
/**
* 登录页:氛围底 + 毛玻璃表单卡
*/
import { ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import AppGlassCard from '@/components/AppGlassCard.vue'
import AppPageAtmosphere from '@/components/AppPageAtmosphere.vue'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
const account = ref('')
const password = ref('')
const loading = ref(false)
onShow(() => {
userStore.hydrateFromStorage()
if (userStore.isLogin) {
uni.switchTab({ url: '/pages/workbench/index' })
}
})
/** 提交登录 */
async function onSubmit() {
if (!account.value.trim() || !password.value) {
uni.showToast({ title: '请输入账号和密码', icon: 'none' })
return
}
if (loading.value) return
loading.value = true
try {
await userStore.login(account.value.trim(), password.value)
uni.showToast({ title: '登录成功', icon: 'success' })
setTimeout(() => {
uni.switchTab({ url: '/pages/workbench/index' })
}, 300)
} catch {
// toast 已在 request 内处理
} finally {
loading.value = false
}
}
</script>
<template>
<AppPageAtmosphere>
<view class="login">
<view class="login__brand">
<!-- 品牌 logo年糕猫+奶酪图片自带圆角CSS 圆角只做兜底裁切 -->
<image class="login__logo" src="/static/logo.png" mode="aspectFill" />
<text class="login__name">NL Admin</text>
<text class="login__desc">移动超管 · 微信小程序 / App / H5</text>
</view>
<AppGlassCard variant="strong" radius="36rpx" padding="40rpx 32rpx">
<view class="login__field">
<text class="login__label">账号</text>
<input v-model="account" class="login__input" placeholder="请输入账号" />
</view>
<view class="login__field">
<text class="login__label">密码</text>
<input
v-model="password"
class="login__input"
password
placeholder="请输入密码"
/>
</view>
<view class="login__btn" :class="{ 'is-disabled': loading }" @tap="onSubmit">
<wd-loading v-if="loading" color="#fff" size="36rpx" />
<text>{{ loading ? '登录中…' : '登录' }}</text>
</view>
</AppGlassCard>
</view>
</AppPageAtmosphere>
</template>
<style scoped lang="scss">
.login {
min-height: 100vh;
padding: 120rpx 40rpx 40rpx;
box-sizing: border-box;
}
.login__brand {
margin-bottom: 64rpx;
}
.login__logo {
display: block;
width: 112rpx;
height: 112rpx;
border-radius: 32rpx;
margin-bottom: 24rpx;
box-shadow: 0 16rpx 40rpx rgba(255, 107, 0, 0.35);
}
.login__name {
display: block;
font-size: 48rpx;
font-weight: 800;
color: $nl-color-text;
}
.login__desc {
display: block;
margin-top: 12rpx;
font-size: 24rpx;
color: $nl-color-text-secondary;
}
.login__field {
margin-bottom: 28rpx;
}
.login__label {
display: block;
font-size: 24rpx;
color: $nl-color-text-secondary;
margin-bottom: 12rpx;
}
.login__input {
height: 88rpx;
padding: 0 28rpx;
/* 底色走输入框变量(亮实白/暗浅灰)+ 中性细边 */
background: $nl-color-input-bg;
border: 1rpx solid $nl-glass-border;
border-radius: $nl-radius-md;
font-size: 28rpx;
color: $nl-color-text;
}
.login__btn {
margin-top: 16rpx;
height: 92rpx;
border-radius: 999rpx;
background: $nl-gradient-primary;
color: #fff;
font-size: 32rpx;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
box-shadow: 0 14rpx 36rpx rgba(255, 107, 0, 0.35);
}
.login__btn.is-disabled {
opacity: 0.7;
}
</style>

View File

@@ -1,14 +1,272 @@
<template>
<view>
</view>
</template>
<script setup lang="ts">
/**
* 我的:氛围底 + 毛玻璃 Hero / 菜单卡 / 深色模式设置
*/
import { ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import AppGlassCard from '@/components/AppGlassCard.vue'
import AppPageAtmosphere from '@/components/AppPageAtmosphere.vue'
import AppTabBar from '@/components/AppTabBar.vue'
import { usePullRefresh } from '@/composables/usePullRefresh'
import { THEME_MODE_OPTIONS, useThemeStore, type ThemeMode } from '@/stores/theme'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
const themeStore = useThemeStore()
const menus = [
{ title: '编辑资料', path: '/pages-sub/mine/pages/profile', icon: 'edit-outline' },
{ title: '登录日志', path: '/pages-sub/mine/pages/login-log', icon: 'view-list' },
{ title: '操作日志', path: '/pages-sub/mine/pages/op-log', icon: 'a-rootlist' },
]
/** 深色模式 action sheet轻量选择器不属于 CRUD 主路径 */
const themeSheetVisible = ref(false)
const themeActions = THEME_MODE_OPTIONS.map((o) => ({ name: o.label, value: o.value }))
/** 选择主题模式并持久化 */
function onThemeSelect({ item }: { item: { name: string; value?: string } }) {
themeStore.setMode((item.value || 'system') as ThemeMode)
themeSheetVisible.value = false
}
/** 刷新个人资料 */
async function refreshMine() {
userStore.hydrateFromStorage()
if (!userStore.isLogin) {
uni.reLaunch({ url: '/pages/login/login' })
return
}
await userStore.fetchUserInfo().catch(() => {})
}
onShow(() => {
uni.hideTabBar({ animation: false })
refreshMine()
})
usePullRefresh(refreshMine)
function go(path: string) {
uni.navigateTo({ url: path })
}
function onLogout() {
uni.showModal({
title: '退出登录',
content: '确定退出当前账号吗?',
success: (res) => {
if (res.confirm) userStore.logout()
},
})
}
function statusBarHeight() {
try {
return uni.getSystemInfoSync().statusBarHeight || 20
} catch {
return 20
}
}
const padTop = `${statusBarHeight() + 12}px`
</script>
<template>
<AppPageAtmosphere>
<view class="nl-page mine" :style="{ paddingTop: padTop }">
<AppGlassCard variant="tint" radius="36rpx" class="hero-card" padding="40rpx 28rpx 44rpx">
<view class="hero">
<view class="hero__avatar">
<image
v-if="userStore.userInfo.avatar"
class="hero__avatar-img"
:src="userStore.userInfo.avatar"
mode="aspectFill"
/>
<text v-else></text>
</view>
<text class="hero__name">{{ userStore.displayName }}</text>
<text class="hero__account">{{ userStore.userInfo.account || '-' }}</text>
</view>
</AppGlassCard>
<AppGlassCard no-padding class="group-card">
<view
v-for="m in menus"
:key="m.path"
class="group__cell"
hover-class="group__cell--press"
@tap="go(m.path)"
>
<view class="group__left">
<view class="group__icon">
<!-- 实橙渐变底 + 白图标与通用列表图标块统一口径 -->
<wd-icon :name="m.icon" size="36rpx" color="#ffffff" />
</view>
<text>{{ m.title }}</text>
</view>
<wd-icon name="arrow-right" size="28rpx" color="var(--nl-color-text-muted, #6b7280)" />
</view>
<!-- 深色模式设置项显示当前模式点开 action sheet 三选 -->
<view
class="group__cell"
hover-class="group__cell--press"
@tap="themeSheetVisible = true"
>
<view class="group__left">
<view class="group__icon">
<wd-icon name="setting" size="36rpx" color="#ffffff" />
</view>
<text>深色模式</text>
</view>
<view class="group__right">
<text class="group__value">{{ themeStore.modeLabel }}</text>
<wd-icon name="arrow-right" size="28rpx" color="var(--nl-color-text-muted, #6b7280)" />
</view>
</view>
</AppGlassCard>
<AppGlassCard
pressable
variant="soft"
class="logout"
padding="0"
@tap="onLogout"
>
<view class="logout__inner">退出登录</view>
</AppGlassCard>
<AppTabBar model-value="mine" />
<!-- 深色模式三选弹层暗色由页面壳 config-provider 提供z-index 必须高于 tabbar(99) -->
<wd-action-sheet
v-model="themeSheetVisible"
:actions="themeActions"
:z-index="120"
cancel-text="取消"
@select="onThemeSelect"
/>
</view>
</AppPageAtmosphere>
</template>
<style scoped lang="scss">
.mine {
padding: 0 28rpx calc(160rpx + env(safe-area-inset-bottom));
min-height: 100vh;
box-sizing: border-box;
}
.hero-card {
margin-bottom: 28rpx;
}
.hero {
display: flex;
flex-direction: column;
align-items: center;
}
.hero__avatar {
width: 152rpx;
height: 152rpx;
border-radius: 50%;
/* 底色走玻璃 strong 变量:亮色近白、暗色深灰,占位字都清晰 */
background: $nl-glass-bg-strong;
border: 2rpx solid $nl-glass-highlight;
color: $nl-color-primary;
font-weight: 800;
font-size: 48rpx;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
margin-bottom: 20rpx;
box-shadow: 0 12rpx 32rpx rgba(255, 107, 0, 0.2);
}
.hero__avatar-img {
width: 100%;
height: 100%;
}
.hero__name {
font-size: 40rpx;
font-weight: 800;
}
.hero__account {
margin-top: 8rpx;
font-size: 24rpx;
color: $nl-color-text-secondary;
}
.group-card {
margin-bottom: 28rpx;
}
.group__cell {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 108rpx;
padding: 0 28rpx;
/* 分割线走主题变量:浅色中性深、暗色中性浅 */
border-bottom: 1rpx solid $nl-color-divider;
font-size: 30rpx;
font-weight: 500;
}
.group__cell:last-child {
border-bottom: none;
}
.group__cell--press {
background: rgba(255, 107, 0, 0.06);
}
.group__left {
display: flex;
align-items: center;
gap: 18rpx;
}
.group__right {
display: flex;
align-items: center;
gap: 8rpx;
}
/* 设置项右侧当前值文案:次要色、小一号 */
.group__value {
font-size: 26rpx;
color: $nl-color-text-secondary;
font-weight: 400;
}
.group__icon {
width: 64rpx;
height: 64rpx;
border-radius: 18rpx;
background: $nl-gradient-primary;
box-shadow: 0 6rpx 16rpx rgba(255, 107, 0, 0.22);
display: flex;
align-items: center;
justify-content: center;
}
.logout {
margin-top: 16rpx;
}
.logout__inner {
height: 92rpx;
display: flex;
align-items: center;
justify-content: center;
color: $nl-color-danger;
font-size: 30rpx;
font-weight: 700;
}
</style>

View File

@@ -0,0 +1,288 @@
<script setup lang="ts">
/**
* 工作台:氛围底 + 毛玻璃面板 + 快捷入口
*/
import { computed, ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import AppGlassCard from '@/components/AppGlassCard.vue'
import AppPageAtmosphere from '@/components/AppPageAtmosphere.vue'
import AppShortcutGrid from '@/components/AppShortcutGrid.vue'
import AppTabBar from '@/components/AppTabBar.vue'
import {
FEATURE_CATEGORIES,
FEATURE_LIST,
getFeatureByKey,
type FeatureCategory,
} from '@/config/features'
import { usePullRefresh } from '@/composables/usePullRefresh'
import { useUserStore } from '@/stores/user'
import { FEATURE_TAB_STORAGE_KEY } from '@/utils/env'
import { getRecentFeatureKeys, pushRecentFeature } from '@/utils/recent'
const userStore = useUserStore()
const recentKeys = ref<string[]>([])
const greeting = computed(() => {
const h = new Date().getHours()
if (h < 12) return '上午好'
if (h < 18) return '下午好'
return '晚上好'
})
const shortcuts = computed(() => {
const keys = recentKeys.value.length
? recentKeys.value
: FEATURE_LIST.filter((f) => f.pinned).map((f) => f.key)
const items = keys
.map((k) => getFeatureByKey(k))
.filter(Boolean)
.slice(0, 8)
.map((f) => ({
key: f!.key,
title: f!.title,
icon: f!.icon,
path: f!.path,
}))
if (items.length) return items
return FEATURE_LIST.slice(0, 8).map((f) => ({
key: f.key,
title: f.title,
icon: f.icon,
path: f.path,
}))
})
const groupLinks = computed(() =>
FEATURE_CATEGORIES.filter((c) => c.key !== 'all').map((c) => ({
key: c.key as FeatureCategory,
label: c.label,
})),
)
/** 刷新最近使用与登录态 */
function refreshWorkbench() {
userStore.hydrateFromStorage()
if (!userStore.isLogin) {
uni.reLaunch({ url: '/pages/login/login' })
return
}
recentKeys.value = getRecentFeatureKeys()
}
onShow(() => {
// H5 每次进 Tab 都藏原生栏,否则会盖住 wd-tabbar round 悬浮变体
uni.hideTabBar({ animation: false })
refreshWorkbench()
})
usePullRefresh(refreshWorkbench)
function openFeature(item: { key: string; path: string }) {
pushRecentFeature(item.key)
uni.navigateTo({ url: item.path })
}
function goMine() {
uni.switchTab({ url: '/pages/mine/mine' })
}
function goFeatureCategory(category: FeatureCategory) {
uni.setStorageSync(FEATURE_TAB_STORAGE_KEY, category)
uni.switchTab({ url: '/pages/feature/index' })
}
</script>
<template>
<AppPageAtmosphere>
<view class="nl-page workbench">
<view class="top">
<view class="top__text">
<text class="top__hi">{{ greeting }}</text>
<text class="top__name">{{ userStore.displayName }}</text>
<text class="top__sub">今天也从容处理管理事务</text>
</view>
<view class="top__avatar" hover-class="top__avatar--press" @tap="goMine">
<image
v-if="userStore.userInfo.avatar"
class="top__avatar-img"
:src="userStore.userInfo.avatar"
mode="aspectFill"
/>
<text v-else></text>
</view>
</view>
<AppGlassCard variant="tint" class="panel-gap">
<view class="stats">
<view class="stats__item">
<text class="stats__num">{{ recentKeys.length }}</text>
<text class="stats__label">最近使用</text>
</view>
<view class="stats__divider" />
<view class="stats__item">
<text class="stats__num">{{ FEATURE_LIST.length }}</text>
<text class="stats__label">可用功能</text>
</view>
</view>
</AppGlassCard>
<AppGlassCard class="panel-gap" no-padding>
<view class="panel-inner">
<text class="panel-inner__title">快捷入口</text>
<AppShortcutGrid :list="shortcuts" @select="openFeature" />
</view>
</AppGlassCard>
<AppGlassCard class="panel-gap" no-padding>
<view class="panel-inner">
<text class="panel-inner__title">功能分类</text>
<scroll-view class="chips" scroll-x :show-scrollbar="false">
<view
v-for="g in groupLinks"
:key="g.key"
class="chips__item"
hover-class="chips__item--press"
@tap="goFeatureCategory(g.key)"
>
{{ g.label }}
</view>
</scroll-view>
</view>
</AppGlassCard>
<AppTabBar model-value="workbench" />
</view>
</AppPageAtmosphere>
</template>
<style scoped lang="scss">
.workbench {
/* 底部 160rpx悬浮 TabBar 占 128rpx高 108 + 底距 20+ 呼吸空间 */
padding: 0 28rpx calc(160rpx + env(safe-area-inset-bottom));
}
.top {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: calc(56rpx + env(safe-area-inset-top)) 8rpx 28rpx;
}
.top__hi {
display: block;
font-size: 28rpx;
color: $nl-color-text-secondary;
}
.top__name {
display: block;
margin-top: 8rpx;
font-size: 48rpx;
font-weight: 800;
color: $nl-color-text;
}
.top__sub {
display: block;
margin-top: 10rpx;
font-size: 24rpx;
color: $nl-color-text-muted;
}
.top__avatar {
width: 96rpx;
height: 96rpx;
border-radius: 50%;
/* 底色走玻璃 strong 变量:亮色近白、暗色深灰,占位字都清晰 */
background: $nl-glass-bg-strong;
border: 2rpx solid $nl-glass-highlight;
color: $nl-color-primary;
font-weight: 700;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 24rpx rgba(255, 107, 0, 0.2);
backdrop-filter: blur(12px);
}
.top__avatar--press {
opacity: 0.85;
transform: scale(0.96);
}
.top__avatar-img {
width: 100%;
height: 100%;
}
.panel-gap {
margin-bottom: 24rpx;
}
.panel-inner {
padding: 28rpx 20rpx 20rpx;
}
.panel-inner__title {
display: block;
padding: 0 12rpx 8rpx;
font-size: 30rpx;
font-weight: 700;
}
.stats {
display: flex;
align-items: center;
padding: 8rpx 12rpx 4rpx;
}
.stats__item {
flex: 1;
text-align: center;
}
.stats__num {
display: block;
font-size: 44rpx;
font-weight: 800;
color: $nl-color-primary;
}
.stats__label {
font-size: 24rpx;
color: $nl-color-text-muted;
}
.stats__divider {
width: 1rpx;
height: 64rpx;
background: rgba(255, 107, 0, 0.16);
}
.chips {
white-space: nowrap;
padding: 8rpx 4rpx 12rpx;
}
.chips__item {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 72rpx;
padding: 0 32rpx;
margin-right: 16rpx;
border-radius: 999rpx;
/* 底色走 chip 变量(亮实白/暗浅灰),描边保持主题橙 */
background: $nl-color-chip-bg;
border: 1rpx solid rgba(255, 107, 0, 0.24);
color: $nl-color-primary;
font-size: 26rpx;
font-weight: 600;
}
.chips__item--press {
opacity: 0.72;
transform: scale(0.96);
}
</style>

BIN
src/static/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="512" height="512" viewBox="0 0 512 512"><defs><clipPath id="master_svg0_25_97"><rect x="0" y="0" width="512" height="512" rx="0"/></clipPath><clipPath id="master_svg1_25_11"><rect x="11" y="39" width="490" height="435" rx="0"/></clipPath></defs><g style="mix-blend-mode:passthrough" clip-path="url(#master_svg0_25_97)"><g clip-path="url(#master_svg1_25_11)"><g><path d="M51.4931,294.222767578125L205.214,437.551767578125C211.594,443.498767578125,220.016,446.812767578125,228.778,446.812767578125C237.54,446.812767578125,245.962,443.498767578125,252.342,437.551767578125L254.554,435.512767578125C238.306,411.638767578125,228.778,382.751767578125,228.778,351.655767578125C228.778,269.073767578125,295.812,202.124767578125,378.5,202.124767578125C402.575,202.124767578125,425.288,207.817767578125,445.45,217.842767578125C446.215,212.320767578125,446.556,206.797767578125,446.556,201.19076757812502L446.556,196.262767578125C446.556,136.874967578125,403.595,86.238267578125,344.983,76.467747578125C306.191,70.010717578125,266.719,82.669897578125,238.986,110.36716757812499L228.778,120.562467578125L218.569,110.36716757812499C190.837,82.669897578125,151.365,70.010717578125,112.573,76.467747578125C53.9601,86.238267578125,11,136.874967578125,11,196.262767578125L11,201.19076757812502C11,236.448767578125,25.6319,270.17876757812496,51.4931,294.222767578125ZM378.5,473.999767578125C446.13,473.999767578125,501,419.199767578125,501,351.655767578125C501,284.112767578125,446.13,229.312767578125,378.5,229.312767578125C310.87,229.312767578125,256,284.112767578125,256,351.655767578125C256,419.199767578125,310.87,473.999767578125,378.5,473.999767578125Z" fill="#2B9939" fill-opacity="1"/></g><g style="mix-blend-mode:passthrough"><path d="M322,415L441,415L441,293.5L419,293.5L419,393L344.5,393L344.5,293.5L322,293.5L322,415Z" fill="#FFFFFF" fill-opacity="1"/></g></g></g></svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 339 B

BIN
src/static/tabbar/mine.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 341 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 B

View File

@@ -1,10 +0,0 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
function increment() {
count.value++
}
return { count, increment }
})

136
src/stores/theme.ts Normal file
View File

@@ -0,0 +1,136 @@
/**
* 主题状态:跟随系统 / 强制浅色 / 强制深色
*
* 切换机制说明(对齐 wot 官方深色方案 https://wot-ui.cn/guide/dark-mode.html
* - 页面壳AppPageAtmosphere / AppPageForm根节点是 wd-config-provider
* theme 取本 store 的 wotTheme'light' | 'dark',已把「跟随系统」解析成具体值)
* - provider 生成 .wot-theme-light / .wot-theme-dark 类wot 组件内部配色随之切换,
* 业务 --nl-* token 也挂在这两个类下(见 app.scss一个开关全局生效、首帧即正确
* - page 元素层保留 prefers-color-scheme 媒体查询兜底(系统模式下预绘制背景不闪)
* - 原生导航栏/窗口背景:系统模式由 theme.json 自动跟随;强制模式逐页用
* setNavigationBarColor + setBackgroundColor 补齐,尽量压缩进页瞬间的异色闪烁
*/
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { THEME_MODE_STORAGE_KEY } from '@/utils/env'
export type ThemeMode = 'system' | 'light' | 'dark'
/** 三种模式的展示文案(设置项 / action sheet 共用) */
export const THEME_MODE_OPTIONS: { value: ThemeMode; label: string }[] = [
{ value: 'system', label: '跟随系统' },
{ value: 'light', label: '浅色' },
{ value: 'dark', label: '深色' },
]
/** 读取系统当前主题:小程序/App 走 getSystemInfoSyncH5 兜底 matchMedia */
function readSystemTheme(): 'light' | 'dark' {
try {
const theme = (uni.getSystemInfoSync() as any)?.theme
if (theme === 'dark' || theme === 'light') return theme
} catch {
// ignore
}
// #ifdef H5
try {
if (window.matchMedia('(prefers-color-scheme: dark)').matches) return 'dark'
} catch {
// ignore
}
// #endif
return 'light'
}
export const useThemeStore = defineStore('theme', () => {
const mode = ref<ThemeMode>('system')
const systemTheme = ref<'light' | 'dark'>('light')
/** 当前实际是否深色(内容与 wot 组件配色以此为准) */
const isDark = computed(() =>
mode.value === 'dark' || (mode.value === 'system' && systemTheme.value === 'dark'),
)
/** 传给页面壳 wd-config-provider 的 theme 值(跟随系统时已解析为具体亮/暗) */
const wotTheme = computed<'light' | 'dark'>(() => (isDark.value ? 'dark' : 'light'))
const modeLabel = computed(
() => THEME_MODE_OPTIONS.find((o) => o.value === mode.value)?.label || '跟随系统',
)
/** 冷启动初始化:恢复偏好 + 监听系统主题变化 */
function init() {
const saved = uni.getStorageSync(THEME_MODE_STORAGE_KEY)
if (saved === 'light' || saved === 'dark' || saved === 'system') {
mode.value = saved
}
systemTheme.value = readSystemTheme()
try {
// 小程序 / App 端系统主题变化回调H5 端部分版本不支持,下面用 matchMedia 兜底
uni.onThemeChange?.((res) => {
if (res?.theme === 'dark' || res?.theme === 'light') {
systemTheme.value = res.theme
}
})
} catch {
// ignore
}
// #ifdef H5
try {
window
.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', (e) => {
systemTheme.value = e.matches ? 'dark' : 'light'
})
} catch {
// ignore
}
// #endif
}
/** 切换主题偏好并持久化 */
function setMode(next: ThemeMode) {
mode.value = next
uni.setStorageSync(THEME_MODE_STORAGE_KEY, next)
applyNavChrome()
}
/**
* 同步当前页原生导航栏与窗口背景色(强制模式用,进页/切换时调用)
* 跟随系统时不动:微信端由 theme.json 自动切换,手动覆盖反而会破坏自动跟随;
* 窗口背景一并设置,减少进页瞬间「导航栏/下拉区域异色」的闪烁感
*/
function applyNavChrome() {
if (mode.value === 'system') return
const dark = mode.value === 'dark'
try {
uni.setNavigationBarColor({
frontColor: dark ? '#ffffff' : '#000000',
backgroundColor: dark ? '#1a1a1a' : '#ffffff',
})
} catch {
// 自定义导航或不支持的端忽略即可
}
try {
const bg = dark ? '#121418' : '#f3f0ec'
uni.setBackgroundColor?.({
backgroundColor: bg,
backgroundColorTop: bg,
backgroundColorBottom: bg,
})
} catch {
// 部分端不支持时忽略
}
}
return {
mode,
systemTheme,
isDark,
wotTheme,
modeLabel,
init,
setMode,
applyNavChrome,
}
})

86
src/stores/user.ts Normal file
View File

@@ -0,0 +1,86 @@
/**
* 登录用户状态token / 资料持久化与退出
*/
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { getUserInfoApi, loginApi } from '@/api/auth'
import { TOKEN_STORAGE_KEY, USER_STORAGE_KEY } from '@/utils/env'
export interface UserInfo {
id?: number
account?: string
nick_name?: string
avatar?: string
phone?: string
email?: string
[key: string]: any
}
export const useUserStore = defineStore('user', () => {
const token = ref('')
const userInfo = ref<UserInfo>({})
const isLogin = computed(() => !!token.value)
const displayName = computed(() => userInfo.value.nick_name || userInfo.value.account || '未登录')
/** 从本地存储恢复登录态(冷启动调用) */
function hydrateFromStorage() {
token.value = uni.getStorageSync(TOKEN_STORAGE_KEY) || ''
try {
const raw = uni.getStorageSync(USER_STORAGE_KEY)
userInfo.value = raw ? (typeof raw === 'string' ? JSON.parse(raw) : raw) : {}
} catch {
userInfo.value = {}
}
}
/** 持久化 token 与用户信息 */
function persist() {
uni.setStorageSync(TOKEN_STORAGE_KEY, token.value || '')
uni.setStorageSync(USER_STORAGE_KEY, JSON.stringify(userInfo.value || {}))
}
/**
* 账号密码登录
* 为什么单独封装页面只关心成功后跳转token 写入集中在 store
*/
async function login(account: string, password: string) {
const res = await loginApi({ account, password })
token.value = (res as any)?.token || (res as any)?.accessToken || ''
if (!token.value) {
throw new Error('登录成功但未返回 token')
}
persist()
await fetchUserInfo()
}
/** 拉取当前用户资料 */
async function fetchUserInfo() {
if (!token.value) return
const info = await getUserInfoApi()
userInfo.value = (info || {}) as UserInfo
persist()
}
/** 退出登录并回登录页 */
function logout() {
token.value = ''
userInfo.value = {}
uni.removeStorageSync(TOKEN_STORAGE_KEY)
uni.removeStorageSync(USER_STORAGE_KEY)
uni.reLaunch({ url: '/pages/login/login' })
}
return {
token,
userInfo,
isLogin,
displayName,
hydrateFromStorage,
persist,
login,
fetchUserInfo,
logout,
}
})

199
src/styles/app.scss Normal file
View File

@@ -0,0 +1,199 @@
/*
* —— 主题变量定义(亮/暗两套)——
* uni.scss 里的 $nl-* 都是 var(--nl-*, 亮色兜底) 引用,真正的取值在这里:
* - page亮色默认值
* - @media (prefers-color-scheme: dark)page 元素兜底暗色(微信端依赖 manifest darkmode:true
* - .wot-theme-light / .wot-theme-dark页面壳 wd-config-provider 生成的主题类(主驱动)
* 注意:这里必须写字面量(不能引用 $nl-*,会循环引用回 var() 自身)
*/
@mixin nl-vars-light {
--nl-color-text: #1f2937;
--nl-color-text-secondary: #4b5563;
--nl-color-text-muted: #6b7280;
--nl-color-border: #e5e7eb;
--nl-color-bg-page: #f3f0ec;
--nl-color-bg-card: #ffffff;
--nl-color-fill-soft: rgba(15, 23, 42, 0.05);
--nl-color-divider: rgba(15, 23, 42, 0.06);
--nl-color-bar-bg: rgba(255, 255, 255, 0.85);
--nl-color-chip-bg: rgba(255, 255, 255, 0.88);
--nl-color-input-bg: rgba(255, 255, 255, 0.9);
--nl-color-skeleton-bone: rgba(236, 239, 243, 0.85);
--nl-color-skeleton-shine: rgba(255, 255, 255, 0.7);
--nl-color-mask-bg: rgba(245, 246, 248, 0.72);
--nl-color-success-deep: #15803d;
--nl-color-danger-deep: #dc2626;
--nl-color-warning-deep: #b45309;
--nl-color-success-soft: rgba(22, 163, 74, 0.12);
--nl-color-danger-soft: rgba(239, 68, 68, 0.1);
--nl-color-warning-soft: rgba(245, 158, 11, 0.14);
/* 玻璃底往实里调0.9 档):只留轻微透感,避免真机上「太毛玻璃」看不清内容 */
--nl-glass-bg: rgba(255, 255, 255, 0.9);
--nl-glass-bg-strong: rgba(255, 255, 255, 0.96);
--nl-glass-bg-tint: rgba(255, 248, 240, 0.93);
--nl-glass-bg-soft: rgba(255, 255, 255, 0.82);
--nl-glass-border: rgba(31, 41, 55, 0.08);
--nl-glass-border-soft: rgba(255, 107, 0, 0.12);
--nl-glass-highlight: rgba(255, 255, 255, 0.9);
--nl-glass-shine: rgba(255, 255, 255, 0.5);
--nl-glass-shadow: 0 2rpx 8rpx rgba(15, 23, 42, 0.04), 0 12rpx 32rpx rgba(15, 23, 42, 0.08);
/* 氛围底整条渐变 + 色斑透明度AppPageAtmosphere 用) */
--nl-atm-warm: linear-gradient(165deg, #fff4ea 0%, #f3f0ec 42%, #eef1f6 100%);
--nl-atm-cool: linear-gradient(165deg, #f4f6f9 0%, #f3f0ec 50%, #e8ecf2 100%);
--nl-atm-blob-opacity: 1;
/* 悬浮 TabBar胶囊底/描边 + 展开药丸底色 + 激活色AppTabBar 用) */
--nl-tabbar-bg: rgba(255, 255, 255, 0.92);
--nl-tabbar-border: rgba(255, 255, 255, 0.6);
--nl-tabbar-pill: rgba(255, 107, 0, 0.12);
--nl-tabbar-active: #ff6b00;
}
@mixin nl-vars-dark {
--nl-color-text: #f3f4f6;
--nl-color-text-secondary: #cbd5e1;
--nl-color-text-muted: #94a3b8;
--nl-color-border: #2a303c;
--nl-color-bg-page: #121418;
--nl-color-bg-card: #1d2129;
--nl-color-fill-soft: rgba(255, 255, 255, 0.08);
--nl-color-divider: rgba(255, 255, 255, 0.07);
--nl-color-bar-bg: rgba(18, 20, 24, 0.88);
--nl-color-chip-bg: rgba(255, 255, 255, 0.08);
--nl-color-input-bg: rgba(255, 255, 255, 0.06);
--nl-color-skeleton-bone: rgba(255, 255, 255, 0.08);
--nl-color-skeleton-shine: rgba(255, 255, 255, 0.1);
--nl-color-mask-bg: rgba(10, 12, 16, 0.72);
/* 暗色下语义文字换亮色系(深绿/深红在黑底上对比度不够) */
--nl-color-success-deep: #4ade80;
--nl-color-danger-deep: #f87171;
--nl-color-warning-deep: #fbbf24;
--nl-color-success-soft: rgba(74, 222, 128, 0.14);
--nl-color-danger-soft: rgba(248, 113, 113, 0.14);
--nl-color-warning-soft: rgba(251, 191, 36, 0.16);
/* 暗色玻璃中性深灰底同样调实0.85 档),氛围色斑只从卡片边缘微微透出;
不用棕色 tint发闷暖意交给背后的氛围色斑透出 */
--nl-glass-bg: rgba(30, 32, 38, 0.85);
--nl-glass-bg-strong: rgba(30, 32, 38, 0.93);
--nl-glass-bg-tint: rgba(38, 34, 31, 0.85);
--nl-glass-bg-soft: rgba(30, 32, 38, 0.76);
--nl-glass-border: rgba(255, 255, 255, 0.1);
--nl-glass-border-soft: rgba(255, 138, 61, 0.14);
--nl-glass-highlight: rgba(255, 255, 255, 0.1);
--nl-glass-shine: rgba(255, 255, 255, 0.05);
--nl-glass-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.28), 0 20rpx 48rpx rgba(0, 0, 0, 0.4);
--nl-atm-warm: linear-gradient(165deg, #201812 0%, #121418 42%, #10141b 100%);
--nl-atm-cool: linear-gradient(165deg, #161a20 0%, #121418 50%, #0f1218 100%);
--nl-atm-blob-opacity: 0.55;
/*
* 暗色 TabBar胶囊底必须比页面底#121418明显亮一档才能「浮」出来
* (之前用 --nl-glass-bg-strong 的 rgba(30,32,38) 和页面底几乎同色,椭圆卡片直接隐形,已踩坑)
*/
--nl-tabbar-bg: rgba(46, 50, 60, 0.92);
--nl-tabbar-border: rgba(255, 255, 255, 0.14);
--nl-tabbar-pill: rgba(255, 138, 61, 0.22);
--nl-tabbar-active: #ff8a3d;
}
page {
@include nl-vars-light;
background-color: var(--nl-color-bg-page);
color: var(--nl-color-text);
font-size: 28rpx;
box-sizing: border-box;
--wot-color-theme: #{$nl-color-primary};
--wot-color-success: #{$nl-color-success};
--wot-color-danger: #{$nl-color-danger};
--wot-color-warning: #f59e0b;
--nl-color-primary: #{$nl-color-primary};
--nl-color-primary-soft: #{$nl-color-primary-soft};
}
/* 跟随系统暗色(微信端由 manifest darkmode:true 启用该媒体查询) */
@media (prefers-color-scheme: dark) {
page {
@include nl-vars-dark;
}
}
/*
* 主题类挂在 wot 官方的 .wot-theme-light / .wot-theme-dark 上
* (由页面壳里的 wd-config-provider 生成,参考 https://wot-ui.cn/guide/dark-mode.html
* 一个开关同时驱动 wot 组件内部配色与业务 --nl-* token
* theme 值由 store 实时解析(含跟随系统),首帧即正确,不依赖媒体查询、不闪烁。
* 显式声明 color否则子树文字会继承 page 层按媒体查询解析的颜色,
* 在「强制主题 ≠ 系统主题」时出现亮底浅字/暗底深字(已踩坑)。
*/
.wot-theme-light {
@include nl-vars-light;
color: var(--nl-color-text);
}
.wot-theme-dark {
@include nl-vars-dark;
color: var(--nl-color-text);
}
.nl-page {
min-height: 100vh;
/* 顶部统一 24rpx 呼吸间距:所有默认导航页与导航栏保持一致距离;
自定义导航的 Tab 页用行内 paddingTop 覆盖,无需单独处理 */
padding-top: 24rpx;
padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
box-sizing: border-box;
}
/* 工具类毛玻璃(轻量场景);复杂卡片优先用 AppGlassCard 组件 */
.nl-card,
.nl-glass {
position: relative;
overflow: hidden;
background: $nl-glass-bg;
border-radius: $nl-radius-lg;
padding: $nl-space-md;
border: 1rpx solid $nl-glass-border;
box-shadow: $nl-glass-shadow;
backdrop-filter: blur($nl-glass-blur) saturate(1.8);
-webkit-backdrop-filter: blur($nl-glass-blur) saturate(1.8);
}
.nl-section-title {
font-size: 30rpx;
font-weight: 600;
color: $nl-color-text;
margin-bottom: $nl-space-sm;
}
.nl-muted {
color: $nl-color-text-secondary;
font-size: 24rpx;
}
.nl-safe-bottom {
padding-bottom: env(safe-area-inset-bottom);
}
.nl-pressable--press {
opacity: 0.88;
transform: scale(0.985);
}
/**
* H5 原生 tabBar 不是 wot 变体,会盖住页面内 shape=round 悬浮栏
* 统一隐藏,三端实际交互走 AppTabBarwd-tabbar
* 注意placeholder 只能隐藏 tabBar 内部的那个!
* 顶部导航栏uni-page-head也用同名 .uni-placeholder 撑开内容区,
* 全局隐藏会导致所有默认导航页的内容滑到导航栏底下(已踩坑)
*/
/* #ifdef H5 */
uni-tabbar,
.uni-tabbar,
.uni-tabbar-bottom,
.uni-tabbar-border,
uni-tabbar .uni-placeholder {
display: none !important;
height: 0 !important;
overflow: hidden !important;
opacity: 0 !important;
pointer-events: none !important;
}
/* #endif */

5
src/styles/tokens.scss Normal file
View File

@@ -0,0 +1,5 @@
/**
* 已废弃独立 tokens 文件。
* $nl-* 定义请改 `src/uni.scss`uni-app 注入后 @import 相对路径会错)。
* 保留本文件仅作占位,避免旧路径误引用;请勿再 @import 本文件。
*/

View File

@@ -1,26 +1,26 @@
{
"light": {
"bgColor": "#fcfcfc",
"bgColorBottom": "#fcfcfc",
"bgColorTop": "#ff6b00",
"bgColor": "#f3f0ec",
"bgColorBottom": "#f3f0ec",
"bgColorTop": "#f3f0ec",
"bgTxtStyle": "dark",
"navBgColor": "#ff6b00",
"navTxtStyle": "white",
"tabBgColor": "#fcfcfc",
"navBgColor": "#ffffff",
"navTxtStyle": "black",
"tabBgColor": "#ffffff",
"tabBorderStyle": "black",
"tabFontColor": "#1f2937",
"tabFontColor": "#9ca3af",
"tabSelectedColor": "#ff6b00"
},
"dark": {
"bgColor": "#181818",
"bgColorBottom": "#181818",
"bgColorTop": "#ff6b00",
"bgColor": "#111111",
"bgColorBottom": "#111111",
"bgColorTop": "#111111",
"bgTxtStyle": "light",
"navBgColor": "#ff6b00",
"navBgColor": "#1a1a1a",
"navTxtStyle": "white",
"tabBgColor": "#181818",
"tabBgColor": "#1a1a1a",
"tabBorderStyle": "white",
"tabFontColor": "#f3f4f6",
"tabFontColor": "#9ca3af",
"tabSelectedColor": "#ff6b00"
}
}

View File

@@ -1,76 +1,120 @@
/**
* 这里是uni-app内置的常用样式变量
*
* uni-app 官方扩展插件及插件市场https://ext.dcloud.net.cn上很多三方插件均使用了这些样式变量
* 如果你是插件开发者建议你使用scss预处理并在插件代码中直接使用这些变量无需 import 这个文件方便用户通过搭积木的方式开发整体风格一致的App
* uni-app 全局 scss 变量(自动注入到每个页面/组件 style 前)
*
* 强制约定(见 .cursor/rules/Uniapp-Scss.mdc
* - $nl-* / $uni-* 必须直接写在本文件,禁止 @import 其它 scss
* - uni.scss 被注入到组件后,其中的 @import 会按「组件文件」相对解析,导致找不到 ./styles/tokens.scss
*/
/**
* 如果你是App开发者插件使用者你可以通过修改这些变量来定制自己的插件主题实现自定义主题功能
*
* 如果你的项目同样使用了scss预处理你也可以直接在你的 scss 代码中使用如下变量,同时无需 import 这个文件
/* —— C 端视觉 token —— */
/*
* 暗色适配架构:
* - 主题相关 token 写成 var(--nl-*, 亮色兜底),运行时由 CSS 变量切换亮/暗
* - 变量的亮/暗定义在 src/styles/app.scsspage 亮色 + prefers-color-scheme 兜底 + .wot-theme-* 主题类)
* - 两套主题共用的(主色/渐变/圆角/间距)保持 SCSS 字面量
*/
$nl-color-primary: #ff6b00;
$nl-color-primary-soft: rgba(255, 107, 0, 0.12);
/* 主色实渐变:图标字块/FAB 等视觉锚点统一用它,暗色下同样成立 */
$nl-gradient-primary: linear-gradient(135deg, #ff8a3d, #ff6b00);
$nl-color-success: #16a34a;
$nl-color-danger: #ef4444;
$nl-color-warning: #d97706;
/* 语义状态「深文字 + 浅底」配对:小字号 pill 保证 4.5:1 对比度(暗色下换亮文字,见 app.scss */
$nl-color-success-deep: var(--nl-color-success-deep, #15803d);
$nl-color-danger-deep: var(--nl-color-danger-deep, #dc2626);
$nl-color-warning-deep: var(--nl-color-warning-deep, #b45309);
$nl-color-success-soft: var(--nl-color-success-soft, rgba(22, 163, 74, 0.12));
$nl-color-danger-soft: var(--nl-color-danger-soft, rgba(239, 68, 68, 0.1));
$nl-color-warning-soft: var(--nl-color-warning-soft, rgba(245, 158, 11, 0.14));
/* 中性可见底:默认态 pill/标签用 */
$nl-color-fill-soft: var(--nl-color-fill-soft, rgba(15, 23, 42, 0.05));
$nl-color-text: var(--nl-color-text, #1f2937);
$nl-color-text-secondary: var(--nl-color-text-secondary, #4b5563);
$nl-color-text-muted: var(--nl-color-text-muted, #6b7280);
$nl-color-border: var(--nl-color-border, #e5e7eb);
$nl-color-bg-page: var(--nl-color-bg-page, #f3f0ec);
$nl-color-bg-card: var(--nl-color-bg-card, #ffffff);
/* 分割线:浅色中性深、暗色中性浅,统一走变量 */
$nl-color-divider: var(--nl-color-divider, rgba(15, 23, 42, 0.06));
/* 半透明底栏/浮层背景AppBottomBar、AppPageForm 底栏等) */
$nl-color-bar-bg: var(--nl-color-bar-bg, rgba(255, 255, 255, 0.85));
/* 实底小组件背景chips、输入框、骨架骨骼 */
$nl-color-chip-bg: var(--nl-color-chip-bg, rgba(255, 255, 255, 0.88));
$nl-color-input-bg: var(--nl-color-input-bg, rgba(255, 255, 255, 0.9));
$nl-color-skeleton-bone: var(--nl-color-skeleton-bone, rgba(236, 239, 243, 0.85));
$nl-color-skeleton-shine: var(--nl-color-skeleton-shine, rgba(255, 255, 255, 0.7));
/* 全屏 loading 遮罩底 */
$nl-color-mask-bg: var(--nl-color-mask-bg, rgba(245, 246, 248, 0.72));
/* 悬浮 TabBar胶囊底/描边 + 展开药丸底色 + 激活色(暗色取值见 app.scss底面刻意比页面底亮 */
$nl-tabbar-bg: var(--nl-tabbar-bg, rgba(255, 255, 255, 0.92));
$nl-tabbar-border: var(--nl-tabbar-border, rgba(255, 255, 255, 0.6));
$nl-tabbar-pill: var(--nl-tabbar-pill, rgba(255, 107, 0, 0.12));
$nl-tabbar-active: var(--nl-tabbar-active, #ff6b00);
/* 毛玻璃卡片 token底色偏实0.9 档),只留轻微透感;太透会发灰、内容对比度不足 */
$nl-glass-bg: var(--nl-glass-bg, rgba(255, 255, 255, 0.9));
$nl-glass-bg-strong: var(--nl-glass-bg-strong, rgba(255, 255, 255, 0.96));
$nl-glass-bg-tint: var(--nl-glass-bg-tint, rgba(255, 248, 240, 0.93));
$nl-glass-bg-soft: var(--nl-glass-bg-soft, rgba(255, 255, 255, 0.82));
$nl-glass-border: var(--nl-glass-border, rgba(31, 41, 55, 0.08));
$nl-glass-border-soft: var(--nl-glass-border-soft, rgba(255, 107, 0, 0.12));
/* 顶部 1px 内高光 + 上沿受光渐变色:保留玻璃质感 */
$nl-glass-highlight: var(--nl-glass-highlight, rgba(255, 255, 255, 0.9));
$nl-glass-shine: var(--nl-glass-shine, rgba(255, 255, 255, 0.5));
/* 双层阴影:近距细影定轮廓 + 远距大影浮起 */
$nl-glass-shadow: var(
--nl-glass-shadow,
0 2rpx 8rpx rgba(15, 23, 42, 0.04),
0 12rpx 32rpx rgba(15, 23, 42, 0.08)
);
/* 模糊半径收敛到 14px保留一点玻璃质感即可24px 的重模糊在真机上「太毛玻璃」 */
$nl-glass-blur: 14px;
$nl-radius-sm: 12rpx;
$nl-radius-md: 20rpx;
$nl-radius-lg: 28rpx;
$nl-radius-xl: 36rpx;
$nl-space-xs: 8rpx;
$nl-space-sm: 16rpx;
$nl-space-md: 24rpx;
$nl-space-lg: 32rpx;
$nl-space-xl: 48rpx;
$nl-touch-min: 88rpx;
/* 颜色变量 */
/* 行为相关颜色 */
$uni-color-primary: #007aff;
$uni-color-success: #4cd964;
$uni-color-warning: #f0ad4e;
$uni-color-error: #dd524d;
/* 文字基本颜色 */
$uni-text-color: #333; // 基本色
$uni-text-color-inverse: #fff; // 反色
$uni-text-color-grey: #999; // 辅助灰色,如加载更多的提示信息
$uni-text-color-placeholder: #808080;
/* —— uni 内置变量映射 —— */
$uni-color-primary: $nl-color-primary;
$uni-color-success: $nl-color-success;
$uni-color-warning: #f59e0b;
$uni-color-error: $nl-color-danger;
$uni-text-color: $nl-color-text;
$uni-text-color-inverse: #fff;
$uni-text-color-grey: $nl-color-text-muted;
$uni-text-color-placeholder: $nl-color-text-muted;
$uni-text-color-disable: #c0c0c0;
/* 背景颜色 */
$uni-bg-color: #fff;
$uni-bg-color-grey: #f8f8f8;
$uni-bg-color-hover: #f1f1f1; // 点击状态颜色
$uni-bg-color-mask: rgba(0, 0, 0, 0.4); // 遮罩颜色
/* 边框颜色 */
$uni-border-color: #c8c7cc;
/* 尺寸变量 */
/* 文字尺寸 */
$uni-bg-color: $nl-color-bg-card;
$uni-bg-color-grey: $nl-color-bg-page;
$uni-bg-color-hover: #f1f1f1;
$uni-bg-color-mask: rgba(0, 0, 0, 0.4);
$uni-border-color: $nl-color-border;
$uni-font-size-sm: 12px;
$uni-font-size-base: 14px;
$uni-font-size-lg: 16px;
/* 图片尺寸 */
$uni-img-size-sm: 20px;
$uni-img-size-base: 26px;
$uni-img-size-lg: 40px;
/* Border Radius */
$uni-border-radius-sm: 2px;
$uni-border-radius-base: 3px;
$uni-border-radius-lg: 6px;
$uni-border-radius-sm: 4px;
$uni-border-radius-base: 8px;
$uni-border-radius-lg: 14px;
$uni-border-radius-circle: 50%;
/* 水平间距 */
$uni-spacing-row-sm: 5px;
$uni-spacing-row-base: 10px;
$uni-spacing-row-lg: 15px;
/* 垂直间距 */
$uni-spacing-col-sm: 4px;
$uni-spacing-col-base: 8px;
$uni-spacing-col-lg: 12px;
/* 透明度 */
$uni-opacity-disabled: 0.3; // 组件禁用态的透明度
/* 文章场景相关 */
$uni-color-title: #2c405a; // 文章标题颜色
$uni-opacity-disabled: 0.3;
$uni-color-title: $nl-color-text;
$uni-font-size-title: 20px;
$uni-color-subtitle: #555; // 二级标题颜色
$uni-color-subtitle: $nl-color-text-secondary;
$uni-font-size-subtitle: 18px;
$uni-color-paragraph: #3f536e; // 文章段落颜色
$uni-color-paragraph: $nl-color-text-secondary;
$uni-font-size-paragraph: 15px;

32
src/utils/env.ts Normal file
View File

@@ -0,0 +1,32 @@
/**
* 环境与 API 地址H5 / 小程序 / App 共用,按运行环境取 baseURL
* 本地开发可改 VITE_API_BASE_URL未配置时回落默认值
*/
const DEFAULT_API_BASE = 'http://127.0.0.1:18006/api'
/** 读取 Vite 环境变量中的 API 根地址 */
export function getApiBaseUrl(): string {
const fromEnv = (import.meta as any).env?.VITE_API_BASE_URL as string | undefined
if (fromEnv && String(fromEnv).trim()) {
return String(fromEnv).replace(/\/$/, '')
}
return DEFAULT_API_BASE
}
/** Token 本地存储键 */
export const TOKEN_STORAGE_KEY = 'nl_admin_token'
/** 用户信息本地存储键 */
export const USER_STORAGE_KEY = 'nl_admin_user'
/** 功能页分类 Tab 本地记忆键 */
export const FEATURE_TAB_STORAGE_KEY = 'nl_feature_tab_key'
/** 功能页筛选条件本地记忆键 */
export const FEATURE_FILTER_STORAGE_KEY = 'nl_feature_filter'
/** 最近使用功能键 */
export const RECENT_FEATURE_STORAGE_KEY = 'nl_recent_features'
/** 深色模式偏好本地存储键system / light / dark */
export const THEME_MODE_STORAGE_KEY = 'nl_theme_mode'

29
src/utils/recent.ts Normal file
View File

@@ -0,0 +1,29 @@
/**
* 最近使用功能:供工作台快捷入口读取
*/
import { RECENT_FEATURE_STORAGE_KEY } from '@/utils/env'
const MAX_RECENT = 8
/** 读取最近使用的功能 key 列表 */
export function getRecentFeatureKeys(): string[] {
try {
const raw = uni.getStorageSync(RECENT_FEATURE_STORAGE_KEY)
if (Array.isArray(raw)) return raw.map(String)
if (typeof raw === 'string' && raw) {
const parsed = JSON.parse(raw)
return Array.isArray(parsed) ? parsed.map(String) : []
}
} catch {
// ignore
}
return []
}
/** 记录一次功能点击,写入最近使用(去重置顶) */
export function pushRecentFeature(key: string) {
if (!key) return
const list = getRecentFeatureKeys().filter((k) => k !== key)
list.unshift(key)
uni.setStorageSync(RECENT_FEATURE_STORAGE_KEY, list.slice(0, MAX_RECENT))
}

169
src/utils/request.ts Normal file
View File

@@ -0,0 +1,169 @@
/**
* 统一请求封装:注入 Token、解包 jok/jerr、401 跳登录
* 业务页禁止直接 uni.request统一走本文件
*/
import { getApiBaseUrl, TOKEN_STORAGE_KEY } from '@/utils/env'
export interface ApiResult<T = any> {
code: number
msg?: string
message?: string
result?: T
data?: T
}
export interface RequestOptions {
url: string
method?: 'GET' | 'POST' | 'PUT' | 'DELETE'
data?: Record<string, any>
params?: Record<string, any>
header?: Record<string, string>
/** 为 true 时不带 Authorization */
skipAuth?: boolean
/** 为 true 时 401 不自动跳转(登录页用) */
silentAuth?: boolean
}
/**
* 拼接 query 字符串
*/
function buildQuery(params?: Record<string, any>): string {
if (!params || !Object.keys(params).length) return ''
const parts: string[] = []
Object.keys(params).forEach((key) => {
const val = params[key]
if (val === undefined || val === null || val === '') return
parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(val))}`)
})
return parts.length ? `?${parts.join('&')}` : ''
}
/**
* 读取本地 token
*/
function getToken(): string {
return uni.getStorageSync(TOKEN_STORAGE_KEY) || ''
}
/**
* 401 清理登录态并跳转登录页
*/
function handleUnauthorized(silent?: boolean) {
uni.removeStorageSync(TOKEN_STORAGE_KEY)
if (silent) return
const pages = getCurrentPages()
const cur = pages[pages.length - 1] as any
const route = cur?.route || ''
if (String(route).includes('pages/login/login')) return
uni.reLaunch({ url: '/pages/login/login' })
}
/**
* 发起请求并解包后端统一响应code === 0 为成功,数据在 result
*/
export function request<T = any>(options: RequestOptions): Promise<T> {
const method = options.method || 'GET'
const base = getApiBaseUrl()
const query = method === 'GET' ? buildQuery(options.params || options.data) : buildQuery(options.params)
const url = `${base}/${options.url.replace(/^\//, '')}${query}`
const header: Record<string, string> = {
'Content-Type': 'application/json',
...(options.header || {}),
}
if (!options.skipAuth) {
const token = getToken()
if (token) header.Authorization = `Bearer ${token}`
}
return new Promise((resolve, reject) => {
uni.request({
url,
method,
data: method === 'GET' ? undefined : options.data,
header,
success: (res) => {
const status = res.statusCode || 0
if (status === 401) {
handleUnauthorized(options.silentAuth)
reject(new Error('未登录或登录已过期'))
return
}
const body = (res.data || {}) as ApiResult<T>
if (status >= 200 && status < 300) {
const code = Number(body.code)
if (code === 0) {
resolve((body.result !== undefined ? body.result : body.data) as T)
return
}
// 后端未授权时 HTTP 恒 200、业务码为 401jexpire
// 只判 HTTP 401 会漏掉这种过期场景,这里补上:清登录态并跳登录
if (code === 401) {
handleUnauthorized(options.silentAuth)
reject(new Error(body.msg || body.message || '登录已过期'))
return
}
const msg = body.msg || body.message || '请求失败'
uni.showToast({ title: msg, icon: 'none' })
reject(new Error(msg))
return
}
const msg = `网络错误(${status})`
uni.showToast({ title: msg, icon: 'none' })
reject(new Error(msg))
},
fail: (err) => {
uni.showToast({ title: '网络异常', icon: 'none' })
reject(err)
},
})
})
}
/** GET 快捷方法 */
export function get<T = any>(url: string, params?: Record<string, any>, opts?: Partial<RequestOptions>) {
return request<T>({ url, method: 'GET', params, ...opts })
}
/** POST 快捷方法 */
export function post<T = any>(url: string, data?: Record<string, any>, opts?: Partial<RequestOptions>) {
return request<T>({ url, method: 'POST', data, ...opts })
}
/**
* 上传文件(图片等),走 multipart返回后端 result
*/
export function uploadFile<T = any>(url: string, filePath: string, name = 'file'): Promise<T> {
const base = getApiBaseUrl()
const token = getToken()
return new Promise((resolve, reject) => {
uni.uploadFile({
url: `${base}/${url.replace(/^\//, '')}`,
filePath,
name,
header: token ? { Authorization: `Bearer ${token}` } : {},
success: (res) => {
if (res.statusCode === 401) {
handleUnauthorized()
reject(new Error('未登录或登录已过期'))
return
}
try {
const body = JSON.parse(res.data) as ApiResult<T>
if (Number(body.code) === 0) {
resolve((body.result !== undefined ? body.result : body.data) as T)
return
}
const msg = body.msg || body.message || '上传失败'
uni.showToast({ title: msg, icon: 'none' })
reject(new Error(msg))
} catch (e) {
reject(e)
}
},
fail: (err) => {
uni.showToast({ title: '上传失败', icon: 'none' })
reject(err)
},
})
})
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Some files were not shown because too many files have changed in this diff Show More