├── src
├── composables
│ ├── index.ts
│ ├── useShare.ts
│ └── useChatgpt.ts
├── static
│ └── images
│ │ ├── me.png
│ │ ├── med.png
│ │ ├── home.png
│ │ └── homed.png
├── shims.d.ts
├── apis
│ ├── login.ts
│ ├── auth.ts
│ ├── types.ts
│ ├── user.ts
│ └── request.ts
├── layouts
│ └── default.vue
├── App.vue
├── main.ts
├── pages
│ ├── generic
│ │ ├── template.ts
│ │ └── index.vue
│ ├── index.vue
│ ├── login.vue
│ ├── weekly
│ │ └── index.vue
│ ├── me.vue
│ ├── config.ts
│ └── chat
│ │ └── index.vue
├── theme.json
├── components.d.ts
├── stores
│ ├── index.ts
│ └── user.ts
├── manifest.json
├── pages.json
├── components
│ ├── Card.vue
│ ├── MessageItem.vue
│ └── mp-html
│ │ ├── mp-html.vue
│ │ ├── node
│ │ └── node.vue
│ │ └── parser.js
├── uni.scss
└── auto-imports.d.ts
├── server
├── .DS_Store
├── README.md
├── app
│ ├── page.tsx
│ ├── layout.tsx
│ └── api
│ │ ├── user
│ │ └── route.ts
│ │ ├── auth
│ │ └── route.ts
│ │ └── chat-stream
│ │ └── route.ts
├── next.config.js
├── postcss.config.js
├── .env.example
├── next-env.d.ts
├── lib
│ ├── db.ts
│ ├── utils.ts
│ └── openAIStream.ts
├── .gitignore
├── tailwind.config.js
├── prisma
│ └── schema.prisma
├── tsconfig.json
├── package.json
└── middleware.ts
├── project.private.config.json
├── tsconfig.json
├── project.config.json
├── index.html
├── README.md
├── LICENSE
├── .gitignore
├── vite.config.ts
├── unocss.config.ts
└── package.json
/src/composables/index.ts:
--------------------------------------------------------------------------------
1 | export * from './useChatgpt'
2 | export * from './useShare'
3 |
--------------------------------------------------------------------------------
/server/.DS_Store:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wangfengyuan/chatgpt-miniprogram/HEAD/server/.DS_Store
--------------------------------------------------------------------------------
/server/README.md:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wangfengyuan/chatgpt-miniprogram/HEAD/server/README.md
--------------------------------------------------------------------------------
/src/static/images/me.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wangfengyuan/chatgpt-miniprogram/HEAD/src/static/images/me.png
--------------------------------------------------------------------------------
/src/static/images/med.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wangfengyuan/chatgpt-miniprogram/HEAD/src/static/images/med.png
--------------------------------------------------------------------------------
/server/app/page.tsx:
--------------------------------------------------------------------------------
1 | const Home = () => {
2 |
3 | return
aichat homepage
;
4 | };
5 |
6 | export default Home;
--------------------------------------------------------------------------------
/src/static/images/home.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wangfengyuan/chatgpt-miniprogram/HEAD/src/static/images/home.png
--------------------------------------------------------------------------------
/src/static/images/homed.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wangfengyuan/chatgpt-miniprogram/HEAD/src/static/images/homed.png
--------------------------------------------------------------------------------
/server/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {}
3 |
4 | module.exports = nextConfig
5 |
--------------------------------------------------------------------------------
/server/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/server/.env.example:
--------------------------------------------------------------------------------
1 | OPENAI_API_KEY=""
2 | DATABASE_URL=""
3 | WX_APPID=""
4 | WX_SECRET=""
5 | JWT_SECRET="123456"
6 | DEFAULT_CREDIT=15
7 | GLM_API_KEY=""
--------------------------------------------------------------------------------
/src/shims.d.ts:
--------------------------------------------------------------------------------
1 |
2 | declare module '*.vue' {
3 | import type { DefineComponent } from 'vue'
4 | const component: DefineComponent<{}, {}, any>
5 | export default component
6 | }
7 |
--------------------------------------------------------------------------------
/server/next-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
4 | // NOTE: This file should not be edited
5 | // see https://nextjs.org/docs/basic-features/typescript for more information.
6 |
--------------------------------------------------------------------------------
/project.private.config.json:
--------------------------------------------------------------------------------
1 | {
2 | "description": "项目私有配置文件。此文件中的内容将覆盖 project.config.json 中的相同字段。项目的改动优先同步到此文件中。详见文档:https://developers.weixin.qq.com/miniprogram/dev/devtools/projectconfig.html",
3 | "projectname": "ai-tools",
4 | "setting": {
5 | "compileHotReLoad": true
6 | }
7 | }
--------------------------------------------------------------------------------
/src/apis/login.ts:
--------------------------------------------------------------------------------
1 | import useStore from '../stores'
2 | const { user } = useStore()
3 |
4 | /**
5 | * 登录拦截
6 | */
7 | export function loginIntercept(callBack: Function) {
8 | if (user.logged) {
9 | callBack()
10 | return
11 | }
12 | uni.navigateTo({
13 | url: '/pages/login',
14 | })
15 | }
16 |
--------------------------------------------------------------------------------
/src/apis/auth.ts:
--------------------------------------------------------------------------------
1 | const TOKEN_KEY = 'token'
2 |
3 | export function getToken(): string {
4 | return uni.getStorageSync(TOKEN_KEY)
5 | }
6 |
7 | export function setToken(token: string) {
8 | return uni.setStorageSync(TOKEN_KEY, token)
9 | }
10 |
11 | export function removeToken() {
12 | return uni.removeStorageSync(TOKEN_KEY)
13 | }
14 |
--------------------------------------------------------------------------------
/src/apis/types.ts:
--------------------------------------------------------------------------------
1 | export interface LoginParams {
2 | phone?: string
3 | code: string
4 | }
5 |
6 | export interface LoginResult {
7 | userId: string
8 | token: string
9 | }
10 |
11 | export interface UserInfoResult {
12 | phone: string
13 | realName: string
14 | avatarUrl: string
15 | nickName: string
16 | vb: number
17 | }
18 |
--------------------------------------------------------------------------------
/src/layouts/default.vue:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
9 |
10 |
11 | 当前剩余次数: {{ credit }}
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/server/lib/db.ts:
--------------------------------------------------------------------------------
1 | import { PrismaClient } from '@prisma/client'
2 |
3 | declare global {
4 | var prisma: PrismaClient
5 | }
6 |
7 | let prisma: PrismaClient;
8 |
9 | if (process.env.NODE_ENV === "production") {
10 | prisma = new PrismaClient();
11 | } else {
12 | if (!global.prisma) {
13 | global.prisma = new PrismaClient();
14 | }
15 | prisma = global.prisma;
16 | }
17 |
18 | export default prisma
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@vue/tsconfig/tsconfig.json",
3 | "compilerOptions": {
4 | "sourceMap": true,
5 | "baseUrl": ".",
6 | "paths": {
7 | "~/*": [
8 | "src/*"
9 | ]
10 | },
11 | "lib": ["esnext", "dom"],
12 | "types": ["@dcloudio/types", "vite/client"]
13 | },
14 | "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"]
15 | }
16 |
--------------------------------------------------------------------------------
/src/composables/useShare.ts:
--------------------------------------------------------------------------------
1 | import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
2 |
3 | export function useShare() {
4 | const options = {
5 | title: '这个回答太有趣了,你也来试试',
6 | imageUrl: 'https://cos.codefe.top/images/ai-home-screenshot.png',
7 | path: '/pages/index',
8 | }
9 | onShareAppMessage(() => (options))
10 |
11 | onShareTimeline(() => (options));
12 | return {
13 | onShareAppMessage,
14 | onShareTimeline,
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/src/App.vue:
--------------------------------------------------------------------------------
1 |
16 |
17 |
20 |
--------------------------------------------------------------------------------
/server/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import { Inter } from 'next/font/google'
2 |
3 | const inter = Inter({ subsets: ['latin'] })
4 |
5 | export const metadata = {
6 | title: 'Create Next App',
7 | description: 'Generated by create next app',
8 | }
9 |
10 | export default function RootLayout({
11 | children,
12 | }: {
13 | children: React.ReactNode
14 | }) {
15 | return (
16 |
17 | {children}
18 |
19 | )
20 | }
21 |
--------------------------------------------------------------------------------
/src/main.ts:
--------------------------------------------------------------------------------
1 | import { createSSRApp } from 'vue'
2 | import UniLayouts from 'virtual:uni-layouts'
3 | import uviewPlus from 'uview-plus'
4 | import App from './App.vue'
5 | import { setupPinia } from './stores'
6 | import 'uno.css'
7 |
8 | export function createApp() {
9 | const app = createSSRApp(App)
10 | // Configure store
11 | // https://pinia.vuejs.org/
12 | setupPinia(app)
13 | // 使用 uView UI
14 | app.use(uviewPlus)
15 | app.use(UniLayouts)
16 |
17 | return { app }
18 | }
19 |
--------------------------------------------------------------------------------
/src/pages/generic/template.ts:
--------------------------------------------------------------------------------
1 | const template = (data: any) => {
2 | let { prompt } = data
3 | const doubleBraceRegex = /{{(\d+|[a-z$_][a-z\d$_]*?(?:\.[a-z\d$_]*?)*?)}}/gi
4 |
5 | if (doubleBraceRegex.test(prompt)) {
6 | prompt = prompt.replace(doubleBraceRegex, (_, key) => {
7 | let result = data
8 | for (const property of key.split('.'))
9 | result = result ? result[property].val : ''
10 | return result
11 | })
12 | }
13 |
14 | return prompt
15 | }
16 |
17 | export default template
18 |
--------------------------------------------------------------------------------
/server/app/api/user/route.ts:
--------------------------------------------------------------------------------
1 | import prisma from '@/lib/db'
2 | import type { User } from "@prisma/client";
3 | import { AuthenticatedRequest } from "@/middleware";
4 |
5 | export async function GET(req: AuthenticatedRequest) {
6 | const userId = req.headers.get("X-USER-ID");
7 | const user: Partial = await prisma.user.findUnique({
8 | where: {
9 | id: userId!,
10 | },
11 | })
12 | delete user!.wx_openid;
13 | return new Response(JSON.stringify({
14 | status: "success",
15 | data: user,
16 | }))
17 | }
--------------------------------------------------------------------------------
/server/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # next.js
12 | /.next/
13 | /out/
14 |
15 | # production
16 | /build
17 |
18 | # misc
19 | .DS_Store
20 | *.pem
21 |
22 | # debug
23 | npm-debug.log*
24 | yarn-debug.log*
25 | yarn-error.log*
26 |
27 | # local env files
28 | .env*.local
29 |
30 | # vercel
31 | .vercel
32 |
33 | # typescript
34 | *.tsbuildinfo
35 | next-env.d.ts
36 |
37 | .env
38 |
--------------------------------------------------------------------------------
/server/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | content: [
4 | './pages/**/*.{js,ts,jsx,tsx,mdx}',
5 | './components/**/*.{js,ts,jsx,tsx,mdx}',
6 | './app/**/*.{js,ts,jsx,tsx,mdx}',
7 | ],
8 | theme: {
9 | extend: {
10 | backgroundImage: {
11 | 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
12 | 'gradient-conic':
13 | 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))',
14 | },
15 | },
16 | },
17 | plugins: [],
18 | }
19 |
--------------------------------------------------------------------------------
/src/theme.json:
--------------------------------------------------------------------------------
1 | {
2 | "dark": {
3 | "bgColor": "#222222",
4 | "bgColorBottom": "#222222",
5 | "bgColorTop": "#222222",
6 | "bgTxtStyle": "light",
7 | "navBgColor": "#222222",
8 | "navTxtStyle": "white",
9 | "tabBgColor": "#222222",
10 | "tabBorderStyle": "white"
11 | },
12 | "light": {
13 | "bgColor": "#FFFFFF",
14 | "bgColorBottom": "#FFFFFF",
15 | "bgColorTop": "#FFFFFF",
16 | "bgTxtStyle": "dark",
17 | "navBgColor": "#FFFFFF",
18 | "navTxtStyle": "white",
19 | "tabBgColor": "#FFFFFF",
20 | "tabBorderStyle": "black"
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/project.config.json:
--------------------------------------------------------------------------------
1 | {
2 | "appid": "wxf5dc1d8d1ecbaaa8",
3 | "compileType": "miniprogram",
4 | "libVersion": "2.30.2",
5 | "packOptions": {
6 | "ignore": [],
7 | "include": []
8 | },
9 | "setting": {
10 | "coverView": true,
11 | "es6": true,
12 | "postcss": true,
13 | "minified": true,
14 | "enhance": true,
15 | "showShadowRootInWxmlPanel": true,
16 | "packNpmRelationList": [],
17 | "babelSetting": {
18 | "ignore": [],
19 | "disablePlugins": [],
20 | "outputPath": ""
21 | }
22 | },
23 | "condition": {},
24 | "editorSetting": {
25 | "tabSize": 2
26 | }
27 | }
--------------------------------------------------------------------------------
/src/components.d.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | /* prettier-ignore */
3 | // @ts-nocheck
4 | // Generated by vite-plugin-uni-components
5 | // Read more: https://github.com/vuejs/core/pull/3399
6 | import '@vue/runtime-core'
7 |
8 | export {}
9 |
10 | declare module '@vue/runtime-core' {
11 | export interface GlobalComponents {
12 | Card: typeof import('./components/Card.vue')['default']
13 | MessageItem: typeof import('./components/MessageItem.vue')['default']
14 | MpHtml: typeof import('./components/mp-html/mp-html.vue')['default']
15 | Node: typeof import('./components/mp-html/node/node.vue')['default']
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
--------------------------------------------------------------------------------
/src/apis/user.ts:
--------------------------------------------------------------------------------
1 | import type { LoginParams, LoginResult, UserInfoResult } from './types'
2 | import request from './request'
3 |
4 | enum Path {
5 | Login = '/api/auth',
6 | User = '/api/user',
7 | Chat = '/api/chat-stream',
8 | }
9 | // 登录
10 | export async function login(form: LoginParams) {
11 | return request.post(Path.Login, form)
12 | }
13 |
14 | // 获取用户信息
15 | export async function getUserInfo() {
16 | return request.get(Path.User)
17 | }
18 |
19 | // openai api
20 | export async function getChatStream(data: any) {
21 | return request.post(Path.Chat, data)
22 | }
23 |
24 | // 退出登录
25 | // export async function logout() {
26 | // return request.get(Path.Logout)
27 | // }
28 |
--------------------------------------------------------------------------------
/server/prisma/schema.prisma:
--------------------------------------------------------------------------------
1 | // This is your Prisma schema file,
2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema
3 |
4 | generator client {
5 | provider = "prisma-client-js"
6 | }
7 |
8 | enum Role {
9 | ADMIN
10 | USER
11 | }
12 |
13 | datasource db {
14 | provider = "mongodb"
15 | url = env("DATABASE_URL")
16 | }
17 |
18 | model User {
19 | id String @id @default(auto()) @map("_id") @db.ObjectId
20 | nickname String?
21 | wx_openid String @unique
22 | credit Int?
23 | role Role @default(USER)
24 | avatar String?
25 | createdAt DateTime @default(now()) @map(name: "created_at")
26 | updatedAt DateTime @updatedAt
27 |
28 | @@map(name: "user")
29 | }
--------------------------------------------------------------------------------
/server/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "forceConsistentCasingInFileNames": true,
9 | "noEmit": true,
10 | "esModuleInterop": true,
11 | "module": "esnext",
12 | "moduleResolution": "node",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "jsx": "preserve",
16 | "incremental": true,
17 | "plugins": [
18 | {
19 | "name": "next"
20 | }
21 | ],
22 | "paths": {
23 | "@/*": ["./*"]
24 | }
25 | },
26 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
27 | "exclude": ["node_modules"]
28 | }
29 |
--------------------------------------------------------------------------------
/src/stores/index.ts:
--------------------------------------------------------------------------------
1 | import { createPinia } from 'pinia'
2 | import { createPersistedState } from 'pinia-persistedstate-plugin'
3 | import user from './user'
4 |
5 | export function setupPinia(app: any) {
6 | const pinia = createPinia()
7 | pinia.use(createPersistedState({
8 | storage: {
9 | getItem(key: string): string | null {
10 | return uni.getStorageSync(key)
11 | },
12 | setItem(key: string, value: string) {
13 | uni.setStorageSync(key, value)
14 | },
15 | removeItem(key: string) {
16 | uni.removeStorage({ key })
17 | },
18 | },
19 | }))
20 | app.use(pinia)
21 | return app
22 | }
23 |
24 | // 统一导出 useStore 方法
25 | export default function useStore() {
26 | return {
27 | user: user(),
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/server/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "chatserver",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "@prisma/client": "^4.15.0",
13 | "@types/node": "20.2.5",
14 | "@types/react": "18.2.9",
15 | "@types/react-dom": "18.2.4",
16 | "autoprefixer": "10.4.14",
17 | "eslint": "8.42.0",
18 | "eslint-config-next": "13.4.4",
19 | "eventsource-parser": "^1.0.0",
20 | "jose": "^4.14.4",
21 | "jsonwebtoken": "^9.0.0",
22 | "next": "13.4.4",
23 | "postcss": "8.4.24",
24 | "react": "18.2.0",
25 | "react-dom": "18.2.0",
26 | "tailwindcss": "3.3.2",
27 | "typescript": "5.1.3"
28 | },
29 | "devDependencies": {
30 | "prisma": "^4.15.0"
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/src/pages/index.vue:
--------------------------------------------------------------------------------
1 |
13 |
14 |
15 |
16 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
35 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |

3 |
4 |
5 | 该小程序仅作为演示,如需部署请按照以下流程操作
6 |
7 |
8 |
9 |
10 | ## 技术栈
11 | - uniapp + Vite + UnoCSS + Pinia + uview-plus
12 | - Nextjs + prisma + mongodb
13 |
14 | ## 功能
15 | - 0成本
16 | - 简洁聊天界面,内容可复制可分享
17 | - 次数限制
18 |
19 |
20 | ## 运行
21 |
22 | 1. 克隆项目
23 | ```bash
24 | git clone https://github.com/wangfengyuan/chatgpt-miniprogram.git
25 | ```
26 |
27 | 2. 进入项目目录
28 | ```bash
29 | cd chatgpt-miniprogram
30 | ```
31 |
32 | 3. 安装依赖
33 | ```bash
34 | pnpm install
35 | ```
36 |
37 | 4. 安装server依赖并在.env文件填入正确的环境变量
38 | ```bash
39 | cd server
40 | cp .env.example .env
41 | pnpm install
42 | pnpm run dev
43 | ```
44 |
45 | 5. 打包小程序并导入开微信开发者工具
46 | ```
47 | pnpm run dev:mp-weixin
48 | ```
49 |
50 |
51 | ## server端部署
52 | 参考文章 [0成本开发ChatGPT微信小程序](https://blog.codefe.top/0%E6%88%90%E6%9C%AC%E5%BC%80%E5%8F%91chatgpt%E5%BE%AE%E4%BF%A1%E5%B0%8F%E7%A8%8B%E5%BA%8F)
53 |
54 |
--------------------------------------------------------------------------------
/src/composables/useChatgpt.ts:
--------------------------------------------------------------------------------
1 | import { getChatStream } from '../apis/user'
2 | import useStore from '../stores'
3 | export function useChatgpt() {
4 | const loading = ref(false)
5 | const output = ref('')
6 | const { user } = useStore()
7 | const generate = async (messages: { role: string; content: string }[]) => {
8 | if (loading.value)
9 | return
10 | loading.value = true
11 | output.value = ''
12 | try {
13 | const res: any = await getChatStream({
14 | messages,
15 | })
16 | if (typeof res === 'string') {
17 | output.value = res
18 | } else if (typeof res === 'object' && res.choices) {
19 | output.value = res.choices[0].content;
20 | }
21 | user.updateCredit(user.credit - 1)
22 | }
23 | catch (e: any) {
24 | uni.showToast({
25 | title: e.message || '超时请重试',
26 | icon: 'error',
27 | })
28 | }
29 | loading.value = false
30 | return output.value
31 | }
32 | return {
33 | loading,
34 | output,
35 | generate,
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/src/stores/user.ts:
--------------------------------------------------------------------------------
1 | import { defineStore } from 'pinia'
2 |
3 | export default defineStore('user', {
4 | state: () => {
5 | return {
6 | userInfo: {} as {
7 | id: string
8 | nickname: string
9 | avatar: string
10 | credit: number
11 | },
12 | }
13 | },
14 | getters: {
15 | logged: (state) => {
16 | const { id } = state.userInfo
17 | return !!id
18 | },
19 | id: (state) => {
20 | return state.userInfo.id
21 | },
22 | nickname: (state) => {
23 | return state.userInfo.nickname
24 | },
25 | avatar: (state) => {
26 | return state.userInfo.avatar
27 | },
28 | credit: (state) => {
29 | return state.userInfo.credit
30 | },
31 | },
32 | actions: {
33 | setUserInfo(userInfo: any) {
34 | Object.assign(this.userInfo, userInfo)
35 | },
36 | updateCredit(num: number) {
37 | Object.assign(this.userInfo, {
38 | credit: num,
39 | })
40 | },
41 | logOut() {
42 | this.userInfo = {}
43 | },
44 | },
45 | })
46 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022-PRESENT Neil Lee
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6 |
7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8 |
9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | lerna-debug.log*
8 | .pnpm-debug.log*
9 |
10 | # Diagnostic reports (https://nodejs.org/api/report.html)
11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
12 |
13 | # Runtime data
14 | pids
15 | *.pid
16 | *.seed
17 | *.pid.lock
18 |
19 | # Directory for instrumented libs generated by jscoverage/JSCover
20 | lib-cov
21 |
22 | # Coverage directory used by tools like istanbul
23 | coverage
24 | *.lcov
25 |
26 | # nyc test coverage
27 | .nyc_output
28 |
29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
30 | .grunt
31 |
32 | # Bower dependency directory (https://bower.io/)
33 | bower_components
34 |
35 | # node-waf configuration
36 | .lock-wscript
37 |
38 | # Compiled binary addons (https://nodejs.org/api/addons.html)
39 | build/Release
40 |
41 | # Dependency directories
42 | node_modules/
43 | jspm_packages/
44 |
45 | # Snowpack dependency directory (https://snowpack.dev/)
46 | web_modules/
47 |
48 | # TypeScript cache
49 | *.tsbuildinfo
50 |
51 | # Optional npm cache directory
52 | .npm
53 |
54 | # Optional eslint cache
55 | .eslintcache
56 |
57 | dist
58 |
--------------------------------------------------------------------------------
/server/middleware.ts:
--------------------------------------------------------------------------------
1 | import { NextRequest, NextResponse } from "next/server";
2 | import { getErrorResponse, verifyJWT } from "./lib/utils";
3 |
4 | export interface AuthenticatedRequest extends NextRequest {
5 | user: {
6 | id: string;
7 | };
8 | }
9 |
10 | export async function middleware(req: NextRequest) {
11 | let token: string | undefined;
12 |
13 | if (req.headers.get("Authorization")?.startsWith("Bearer ")) {
14 | token = req.headers.get("Authorization")?.substring(7);
15 | }
16 |
17 | if (!token) {
18 | return getErrorResponse(
19 | 401,
20 | "You are not logged in. Please provide a token to gain access."
21 | );
22 | }
23 |
24 | const response = NextResponse.next();
25 |
26 | try {
27 | if (token) {
28 | const { sub } = await verifyJWT<{ sub: string }>(token);
29 | (req as AuthenticatedRequest).user = { id: sub };
30 | response.headers.set("X-USER-ID", sub);
31 | }
32 | } catch (error) {
33 | return getErrorResponse(401, "Token is invalid or user doesn't exists");
34 | }
35 |
36 | return response;
37 | }
38 |
39 | export const config = {
40 | matcher: ["/api/user/:path*", "/api/chat-stream"],
41 | };
42 |
--------------------------------------------------------------------------------
/src/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "name" : "AI问答Bot",
3 | "appid" : "__UNI__D8AA0CD",
4 | "description" : "",
5 | "versionName" : "0.1.0",
6 | "versionCode" : "100",
7 | "transformPx" : false,
8 | "mp-weixin" : {
9 | "appid" : "wxf5dc1d8d1ecbaaa8",
10 | "setting" : {
11 | "ignoreDevUnusedFiles" : false,
12 | "ignoreUploadUnusedFiles" : false,
13 | "urlCheck" : false,
14 | "es6" : true,
15 | "minified" : true,
16 | "postcss" : true
17 | },
18 | "usingComponents" : true,
19 | "darkmode" : true
20 | },
21 | "uniStatistics" : {
22 | "enable" : false
23 | },
24 | "vueVersion" : "3",
25 | "app-plus" : {
26 | "modules" : {
27 | "OAuth" : {}
28 | },
29 | "distribute" : {
30 | "android" : {
31 | "permissions" : []
32 | },
33 | "sdkConfigs" : {
34 | "oauth" : {
35 | "weixin" : {
36 | "appid" : "wxf5dc1d8d1ecbaaa8",
37 | "UniversalLinks" : ""
38 | }
39 | }
40 | }
41 | }
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/server/app/api/auth/route.ts:
--------------------------------------------------------------------------------
1 | import { NextRequest } from "next/server";
2 | import prisma from '@/lib/db'
3 | import type { User } from "@prisma/client";
4 | import { signJWT } from "@/lib/utils";
5 |
6 | const { WX_APPID, WX_SECRET, DEFAULT_CREDIT } = process.env;
7 |
8 | /**
9 | * 注册用户
10 | * @param req
11 | * @returns
12 | */
13 | export async function POST(req: NextRequest) {
14 | const body = await req.json();
15 | const { code } = body;
16 | const response = await fetch(`https://api.weixin.qq.com/sns/jscode2session?appid=${WX_APPID}&secret=${WX_SECRET}&js_code=${code}&grant_type=authorization_code`)
17 | const jscode2session = await response.json();
18 |
19 | let user: Partial = await prisma.user.findUnique({
20 | where: {
21 | wx_openid: jscode2session.openid,
22 | },
23 | })
24 | if (!user) {
25 | user = await prisma.user.create({
26 | data: {
27 | wx_openid: jscode2session.openid,
28 | credit: Number(DEFAULT_CREDIT),
29 | },
30 | })
31 | }
32 | const token = await signJWT({ sub: user.id! }, { exp: '7d' });
33 | delete user.wx_openid;
34 | return new Response(JSON.stringify({
35 | status: "success",
36 | data: {
37 | token,
38 | user,
39 | },
40 | }))
41 | }
42 |
--------------------------------------------------------------------------------
/server/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from "next/server";
2 | import { SignJWT, jwtVerify } from "jose";
3 |
4 | export function getErrorResponse(
5 | status: number = 500,
6 | message: string,
7 | errors: any = null
8 | ) {
9 | return new NextResponse(
10 | JSON.stringify({
11 | status: status < 500 ? "fail" : "error",
12 | message,
13 | errors: errors ? errors.flatten() : null,
14 | }),
15 | {
16 | status,
17 | headers: { "Content-Type": "application/json" },
18 | }
19 | );
20 | }
21 |
22 | export const signJWT = async (
23 | payload: { sub: string },
24 | options: { exp: string }
25 | ) => {
26 | try {
27 | const secret = new TextEncoder().encode(process.env.JWT_SECRET);
28 | const alg = "HS256";
29 | return new SignJWT(payload)
30 | .setProtectedHeader({ alg })
31 | .setExpirationTime(options.exp)
32 | .setIssuedAt()
33 | .setSubject(payload.sub)
34 | .sign(secret);
35 | } catch (error) {
36 | throw error;
37 | }
38 | };
39 |
40 | export const verifyJWT = async (token: string): Promise => {
41 | try {
42 | return (
43 | await jwtVerify(
44 | token,
45 | new TextEncoder().encode(process.env.JWT_SECRET)
46 | )
47 | ).payload as unknown as T;
48 | } catch (error) {
49 | throw new Error("Your token has expired.");
50 | }
51 | };
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { resolve } from 'path'
2 | import { defineConfig } from 'vite'
3 | import Uni from '@dcloudio/vite-plugin-uni'
4 | import UnoCSS from 'unocss/vite'
5 | import AutoImport from 'unplugin-auto-import/vite'
6 | import Components from '@uni-helper/vite-plugin-uni-components'
7 | import Inspect from 'vite-plugin-inspect'
8 | import UniLayouts from '@uni-helper/vite-plugin-uni-layouts'
9 | import commonjs from '@rollup/plugin-commonjs'
10 |
11 | // https://vitejs.dev/config/
12 | export default defineConfig({
13 | root: process.cwd(),
14 | resolve: {
15 | alias: {
16 | '~/': `${resolve(__dirname, 'src')}/`,
17 | },
18 | },
19 | plugins: [
20 | commonjs(),
21 | UniLayouts(),
22 | // Make sure it before `Uni()`
23 | Components({
24 | extensions: ['vue', 'md'],
25 | dts: 'src/components.d.ts',
26 | }),
27 |
28 | Uni(),
29 |
30 | // https://github.com/antfu/unplugin-auto-import
31 | AutoImport({
32 | imports: ['vue', 'pinia', 'uni-app'],
33 | dts: 'src/auto-imports.d.ts',
34 | dirs: ['src/composables', 'src/stores'],
35 | vueTemplate: true,
36 | }),
37 |
38 | // https://github.com/antfu/unocss
39 | // see unocss.config.ts for config
40 | UnoCSS(),
41 |
42 | // https://github.com/antfu/vite-plugin-inspect
43 | // Visit http://localhost:port/__inspect/ to see the inspector
44 | Inspect(),
45 | ],
46 | })
47 |
--------------------------------------------------------------------------------
/src/pages.json:
--------------------------------------------------------------------------------
1 | {
2 | "globalStyle": {
3 | "backgroundColor": "@bgColor",
4 | "backgroundColorBottom": "@bgColorBottom",
5 | "backgroundColorTop": "@bgColorTop",
6 | "backgroundTextStyle": "dark",
7 | "navigationBarBackgroundColor": "#000000",
8 | "navigationBarTextStyle": "black",
9 | "navigationBarTitleText": "AI问答Bot",
10 | "navigationStyle": "custom"
11 | },
12 | "pages": [
13 | { "path": "pages/index" },
14 | { "path": "pages/me" },
15 | { "path": "pages/login" },
16 | { "path": "pages/chat/index" },
17 | { "path": "pages/weekly/index" },
18 | { "path": "pages/generic/index" }
19 | ],
20 | "tabBar": {
21 | "color": "#444444",
22 | "selectedColor": "#6190E8",
23 | "backgroundColor": "#ffffff",
24 | "list": [
25 | {
26 | "pagePath": "pages/index",
27 | "text": "首页",
28 | "iconPath": "static/images/home.png",
29 | "selectedIconPath": "static/images/homed.png"
30 | },
31 | {
32 | "pagePath": "pages/me",
33 | "text": "我的",
34 | "iconPath": "static/images/me.png",
35 | "selectedIconPath": "static/images/med.png"
36 | }
37 | ]
38 | },
39 | "easycom": {
40 | "autoscan": true,
41 | "custom": {
42 | "^u-(.*)": "uview-plus/components/u-$1/u-$1.vue",
43 | "^uni-(.*)": "@dcloudio/uni-ui/lib/uni-$1/uni-$1.vue"
44 | }
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/src/components/Card.vue:
--------------------------------------------------------------------------------
1 |
29 |
30 |
31 |
32 |
33 |
34 |
![]()
35 |
36 |
37 |
38 | {{ card.title }}
39 |
40 |
41 | {{ card.desc }}
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
51 |
--------------------------------------------------------------------------------
/server/app/api/chat-stream/route.ts:
--------------------------------------------------------------------------------
1 | import type { NextRequest } from "next/server";
2 | import prisma from '@/lib/db'
3 | import { ChatGPTMessage, OpenAIStream, OpenAIStreamPayload } from "@/lib/openAIStream";
4 | import { User } from "@prisma/client";
5 |
6 | const handler = async (req: NextRequest) => {
7 | const body = await req.json();
8 | const { messages } = body;
9 | const userId = req.headers.get("X-USER-ID");
10 | const user: User | null = await prisma.user.findUnique({
11 | where: {
12 | id: userId!,
13 | },
14 | });
15 | if (!user) {
16 | return new Response(JSON.stringify({
17 | status: "fail",
18 | message: '请先登录',
19 | }))
20 | }
21 | const isAdmin = user.role === 'ADMIN';
22 | if (!isAdmin && (!user.credit || user.credit <= 0)) {
23 | return new Response(JSON.stringify({
24 | status: "fail",
25 | message: '使用次数不足',
26 | }))
27 | }
28 |
29 | if (!messages) {
30 | return new Response(JSON.stringify({
31 | status: "fail",
32 | message: '请先输入你的问题',
33 | }))
34 | }
35 |
36 |
37 | const payload: OpenAIStreamPayload = {
38 | model: "gpt-3.5-turbo",
39 | messages,
40 | temperature: 0.7,
41 | top_p: 1,
42 | max_tokens: 800,
43 | stream: true,
44 | };
45 |
46 | const stream = await OpenAIStream(payload);
47 | !isAdmin && await prisma.user.update({
48 | where: {
49 | id: userId!,
50 | },
51 | data: {
52 | credit: {
53 | decrement: 1
54 | },
55 | },
56 | })
57 | return new Response(stream);
58 |
59 | };
60 |
61 | export { handler as POST, handler as GET };
62 |
63 | // export const runtime = "edge";
64 |
--------------------------------------------------------------------------------
/src/pages/login.vue:
--------------------------------------------------------------------------------
1 |
30 |
31 |
32 |
33 |
38 |
39 |
40 |
41 |
42 |
43 | AI问答Bot
44 |
45 |
46 | 上下文聊天, 邮件日周报生成, 写文案, 等你来试
47 |
48 |
49 |
50 |
51 | 微信一键登录
52 |
53 |
54 |
55 |
56 |
57 |
79 |
--------------------------------------------------------------------------------
/src/apis/request.ts:
--------------------------------------------------------------------------------
1 | import { getToken } from './auth'
2 | export interface Result {
3 | code: number
4 | msg: string
5 | data: T
6 | }
7 |
8 | const baseUrl = {
9 | dev: 'http://localhost:3000',
10 | production: 'https://aichat.codefe.top',
11 | }
12 |
13 | type MethodType =
14 | | 'OPTIONS'
15 | | 'GET'
16 | | 'HEAD'
17 | | 'POST'
18 | | 'PUT'
19 | | 'DELETE'
20 | | 'TRACE'
21 | | 'CONNECT'
22 |
23 | class Request {
24 | public request(method: MethodType, url: string, data?: any) {
25 | const token = getToken()
26 | return new Promise((resolve, reject) => {
27 | let result: any
28 | uni.request({
29 | url: `${baseUrl.production}${url}`,
30 | method,
31 | timeout: 15000,
32 | header: {
33 | 'cookie': `token=${token}`,
34 | 'Authorization': `Bearer ${token || ''}`,
35 | 'content-type':
36 | method === 'GET'
37 | ? 'application/json; charset=utf-8'
38 | : 'application/json',
39 | },
40 | data,
41 | success: (res: any) => {
42 | if (/chat-stream/.test(url) && res.statusCode === 401) {
43 | return uni.navigateTo({
44 | url: '/pages/login',
45 | })
46 | }
47 | if (typeof res.data === 'string')
48 | return resolve(res.data)
49 |
50 | result = res.data
51 | if (!result)
52 | throw new Error('[HTTP] Request has no return value')
53 | const { status, message, data } = result
54 | if (status === 'success')
55 | resolve(data)
56 | else
57 | reject(new Error(message || 'Error'))
58 | },
59 | fail: (err) => {
60 | reject(err)
61 | },
62 | })
63 | })
64 | }
65 |
66 | public get(url: string, data?: any) {
67 | return this.request('GET', url, data) as T
68 | }
69 |
70 | public post(url: string, data: any) {
71 | return this.request('POST', url, data) as T
72 | }
73 | }
74 |
75 | export default new Request()
76 |
--------------------------------------------------------------------------------
/src/components/MessageItem.vue:
--------------------------------------------------------------------------------
1 |
44 |
45 |
46 |
47 |
52 |
53 |
54 |
55 | 处理中...
56 |
57 |
58 |
59 |
60 |
![]()
65 |
66 |
67 |
--------------------------------------------------------------------------------
/src/pages/weekly/index.vue:
--------------------------------------------------------------------------------
1 |
16 |
17 |
18 |
19 |
20 |
24 |
25 |
26 |
27 |
28 |
29 |
34 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 | 生成内容
52 |
53 |
54 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
72 |
--------------------------------------------------------------------------------
/src/uni.scss:
--------------------------------------------------------------------------------
1 | /**
2 | * 这里是uni-app内置的常用样式变量
3 | *
4 | * uni-app 官方扩展插件及插件市场(https://ext.dcloud.net.cn)上很多三方插件均使用了这些样式变量
5 | * 如果你是插件开发者,建议你使用scss预处理,并在插件代码中直接使用这些变量(无需 import 这个文件),方便用户通过搭积木的方式开发整体风格一致的App
6 | *
7 | */
8 |
9 | /**
10 | * 如果你是App开发者(插件使用者),你可以通过修改这些变量来定制自己的插件主题,实现自定义主题功能
11 | *
12 | * 如果你的项目同样使用了scss预处理,你也可以直接在你的 scss 代码中使用如下变量,同时无需 import 这个文件
13 | */
14 |
15 | /* 颜色变量 */
16 |
17 | /* 行为相关颜色 */
18 | $uni-color-primary: #007aff;
19 | $uni-color-success: #4cd964;
20 | $uni-color-warning: #f0ad4e;
21 | $uni-color-error: #dd524d;
22 |
23 | /* 文字基本颜色 */
24 | $uni-text-color:#333;//基本色
25 | $uni-text-color-inverse:#fff;//反色
26 | $uni-text-color-grey:#999;//辅助灰色,如加载更多的提示信息
27 | $uni-text-color-placeholder: #808080;
28 | $uni-text-color-disable:#c0c0c0;
29 |
30 | /* 背景颜色 */
31 | $uni-bg-color:#ffffff;
32 | $uni-bg-color-grey:#f8f8f8;
33 | $uni-bg-color-hover:#f1f1f1;//点击状态颜色
34 | $uni-bg-color-mask:rgba(0, 0, 0, 0.4);//遮罩颜色
35 |
36 | /* 边框颜色 */
37 | $uni-border-color:#c8c7cc;
38 |
39 | /* 尺寸变量 */
40 |
41 | /* 文字尺寸 */
42 | $uni-font-size-sm:24rpx;
43 | $uni-font-size-base:28rpx;
44 | $uni-font-size-lg:32rpx;
45 |
46 | /* 图片尺寸 */
47 | $uni-img-size-sm:40rpx;
48 | $uni-img-size-base:52rpx;
49 | $uni-img-size-lg:80rpx;
50 |
51 | /* Border Radius */
52 | $uni-border-radius-sm: 4rpx;
53 | $uni-border-radius-base: 6rpx;
54 | $uni-border-radius-lg: 12rpx;
55 | $uni-border-radius-circle: 50%;
56 |
57 | /* 水平间距 */
58 | $uni-spacing-row-sm: 10px;
59 | $uni-spacing-row-base: 20rpx;
60 | $uni-spacing-row-lg: 30rpx;
61 |
62 | /* 垂直间距 */
63 | $uni-spacing-col-sm: 8rpx;
64 | $uni-spacing-col-base: 16rpx;
65 | $uni-spacing-col-lg: 24rpx;
66 |
67 | /* 透明度 */
68 | $uni-opacity-disabled: 0.3; // 组件禁用态的透明度
69 |
70 | /* 文章场景相关 */
71 | $uni-color-title: #2C405A; // 文章标题颜色
72 | $uni-font-size-title:40rpx;
73 | $uni-color-subtitle: #555555; // 二级标题颜色
74 | $uni-font-size-subtitle:36rpx;
75 | $uni-color-paragraph: #3F536E; // 文章段落颜色
76 | $uni-font-size-paragraph:30rpx;
77 |
78 | @import 'uview-plus/theme.scss';
79 |
80 | .u-cell {
81 | .u-cell__body {
82 | padding-left: 0;
83 | padding-right: 0;
84 | }
85 | }
86 | #tool-box .u-button--info {
87 | background: transparent;
88 | border: none;
89 | }
90 | #home .u-notice__content__text {
91 | color: inherit;
92 | }
--------------------------------------------------------------------------------
/server/lib/openAIStream.ts:
--------------------------------------------------------------------------------
1 | import {
2 | createParser,
3 | ParsedEvent,
4 | ReconnectInterval,
5 | } from "eventsource-parser";
6 |
7 | export type ChatGPTAgent = "user" | "system";
8 |
9 | export interface ChatGPTMessage {
10 | role: ChatGPTAgent;
11 | content: string;
12 | }
13 |
14 | export interface OpenAIStreamPayload {
15 | model: string;
16 | temperature: number;
17 | messages:ChatGPTMessage[]
18 | top_p: number;
19 | max_tokens: number;
20 | stream: boolean;
21 | }
22 | export async function OpenAIStream(payload: OpenAIStreamPayload) {
23 | const encoder = new TextEncoder();
24 | const decoder = new TextDecoder();
25 |
26 | let counter = 0;
27 |
28 | const res = await fetch("https://api.openai.com/v1/chat/completions", {
29 | headers: {
30 | "Content-Type": "application/json",
31 | Authorization: `Bearer ${process.env.OPENAI_API_KEY ?? ""}`,
32 | },
33 | method: "POST",
34 | body: JSON.stringify(payload),
35 | });
36 |
37 | const stream = new ReadableStream({
38 | async start(controller) {
39 | // callback
40 | function onParse(event: ParsedEvent | ReconnectInterval) {
41 | if (event.type === "event") {
42 | const data = event.data;
43 | // https://beta.openai.com/docs/api-reference/completions/create#completions/create-stream
44 | if (data === "[DONE]") {
45 | controller.close();
46 | return;
47 | }
48 | try {
49 | const json = JSON.parse(data);
50 | // const text = json.choices[0].text;
51 | const text = json.choices[0].delta?.content || "";
52 |
53 | if (counter < 2 && (text.match(/\n/) || []).length) {
54 | // this is a prefix character (i.e., "\n\n"), do nothing
55 | return;
56 | }
57 | const queue = encoder.encode(text);
58 | controller.enqueue(queue);
59 | counter++;
60 | } catch (e) {
61 | // maybe parse error
62 | controller.error(e);
63 | }
64 | }
65 | }
66 |
67 | // stream response (SSE) from OpenAI may be fragmented into multiple chunks
68 | // this ensures we properly read chunks and invoke an event for each SSE event stream
69 | const parser = createParser(onParse);
70 | // https://web.dev/streams/#asynchronous-iteration
71 | for await (const chunk of res.body as any) {
72 | parser.feed(decoder.decode(chunk));
73 | }
74 | },
75 | });
76 |
77 | return stream;
78 | }
--------------------------------------------------------------------------------
/src/pages/generic/index.vue:
--------------------------------------------------------------------------------
1 |
45 |
46 |
47 |
48 |
52 |
53 |
54 |
55 |
56 |
57 |
61 |
62 |
63 |
64 |
65 |
66 |
77 |
78 |
79 |
80 |
81 |
84 |
--------------------------------------------------------------------------------
/unocss.config.ts:
--------------------------------------------------------------------------------
1 | import {
2 | defineConfig,
3 | presetAttributify,
4 | presetIcons,
5 | presetUno,
6 | transformerDirectives,
7 | transformerVariantGroup,
8 | } from 'unocss'
9 |
10 | import {
11 | presetApplet,
12 | presetRemRpx,
13 | transformerApplet,
14 | transformerAttributify,
15 | } from 'unocss-applet'
16 |
17 | const isApplet = process.env?.UNI_PLATFORM?.startsWith('mp')
18 |
19 | export default defineConfig({
20 | // shortcuts: {
21 | // 'u-bg': 'bg-gray-100 dark:bg-black',
22 | // 'u-bg-2': 'bg-white dark:bg-[#1C1C1E]',
23 | // 'u-border': 'border-[#EBEDF0] dark:border-[#3A3A3C]',
24 | // 'u-active': 'bg-[#F2F3F5] dark:!bg-[#3A3A3C]',
25 | // 'u-active-h5': 'active:bg-[#F2F3F5] active:dark:bg-[#3A3A3C]',
26 | // 'u-text-color': 'text-[#323233] dark:text-[#F5F5F5]',
27 | // 'u-text-color-2': 'text-[#969799] dark:text-[#707070]',
28 | // 'u-text-color-3': 'text-[#C8C9CC] dark:text-[#4D4D4D]',
29 | // 'bg-primary': 'bg-light-blue-500 dark:bg-light-blue-600',
30 | // },
31 | presets: [
32 | presetIcons({
33 | scale: 1.2,
34 | warn: true,
35 | extraProperties: {
36 | 'display': 'inline-block',
37 | 'vertical-align': 'middle',
38 | },
39 | }),
40 | // presetUno(),
41 | presetApplet({ enable: isApplet }),
42 | /**
43 | * you can add `presetAttributify()` here to enable unocss attributify mode prompt
44 | * although preset is not working for applet, but will generate useless css
45 | */
46 | presetAttributify(),
47 | presetRemRpx({ mode: isApplet ? 'rem2rpx' : 'rpx2rem' }),
48 | ],
49 | transformers: [
50 | transformerDirectives(),
51 | transformerVariantGroup(),
52 | // Don't change the following order
53 | transformerAttributify({ ignoreAttributes: ['block'] }),
54 | transformerApplet(),
55 | ],
56 | rules: [
57 | [
58 | 'p-safe',
59 | {
60 | padding:
61 | 'env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left)',
62 | },
63 | ],
64 | ['pt-safe', { 'padding-top': 'env(safe-area-inset-top)' }],
65 | ['pb-safe', { 'padding-bottom': 'env(safe-area-inset-bottom)' }],
66 | // 多行文本超出部分省略号 line-n
67 | [/^line-(\d+)$/, ([, l]) => {
68 | if (~~l === 1) {
69 | return {
70 | 'overflow': 'hidden',
71 | 'text-overflow': 'ellipsis',
72 | 'white-space': 'nowrap',
73 | 'width': '100%',
74 | }
75 | }
76 | return {
77 | 'overflow': 'hidden',
78 | 'display': '-webkit-box',
79 | '-webkit-box-orient': 'vertical',
80 | '-webkit-line-clamp': l,
81 | }
82 | }],
83 | ],
84 | })
85 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "ai-tools",
3 | "version": "0.1.0",
4 | "scripts": {
5 | "dev:app": "uni -p app",
6 | "dev:custom": "uni -p",
7 | "dev:h5": "uni",
8 | "dev:h5:ssr": "uni --ssr",
9 | "dev:mp-alipay": "uni -p mp-alipay",
10 | "dev:mp-baidu": "uni -p mp-baidu",
11 | "dev:mp-kuaishou": "uni -p mp-kuaishou",
12 | "dev:mp-lark": "uni -p mp-lark",
13 | "dev:mp-qq": "uni -p mp-qq",
14 | "dev:mp-toutiao": "uni -p mp-toutiao",
15 | "dev:mp-weixin": "uni -p mp-weixin",
16 | "dev:quickapp-webview": "uni -p quickapp-webview",
17 | "dev:quickapp-webview-huawei": "uni -p quickapp-webview-huawei",
18 | "dev:quickapp-webview-union": "uni -p quickapp-webview-union",
19 | "build:app": "uni build -p app",
20 | "build:custom": "uni build -p",
21 | "build:h5": "uni build",
22 | "build:h5:ssr": "uni build --ssr",
23 | "build:mp-alipay": "uni build -p mp-alipay",
24 | "build:mp-baidu": "uni build -p mp-baidu",
25 | "build:mp-kuaishou": "uni build -p mp-kuaishou",
26 | "build:mp-lark": "uni build -p mp-lark",
27 | "build:mp-qq": "uni build -p mp-qq",
28 | "build:mp-toutiao": "uni build -p mp-toutiao",
29 | "build:mp-weixin": "uni build -p mp-weixin",
30 | "build:quickapp-webview": "uni build -p quickapp-webview",
31 | "build:quickapp-webview-huawei": "uni build -p quickapp-webview-huawei",
32 | "build:quickapp-webview-union": "uni build -p quickapp-webview-union",
33 | "lint": "eslint .",
34 | "lint:fix": "nr lint --fix"
35 | },
36 | "dependencies": {
37 | "@dcloudio/uni-app": "3.0.0-alpha-3070620230227001",
38 | "@dcloudio/uni-app-plus": "3.0.0-alpha-3070620230227001",
39 | "@dcloudio/uni-components": "3.0.0-alpha-3070620230227001",
40 | "@dcloudio/uni-h5": "3.0.0-alpha-3070620230227001",
41 | "@dcloudio/uni-mp-alipay": "3.0.0-alpha-3070620230227001",
42 | "@dcloudio/uni-mp-baidu": "3.0.0-alpha-3070620230227001",
43 | "@dcloudio/uni-mp-kuaishou": "3.0.0-alpha-3070620230227001",
44 | "@dcloudio/uni-mp-lark": "3.0.0-alpha-3070620230227001",
45 | "@dcloudio/uni-mp-qq": "3.0.0-alpha-3070620230227001",
46 | "@dcloudio/uni-mp-toutiao": "3.0.0-alpha-3070620230227001",
47 | "@dcloudio/uni-mp-weixin": "3.0.0-alpha-3070620230227001",
48 | "@dcloudio/uni-quickapp-webview": "3.0.0-alpha-3070620230227001",
49 | "@dcloudio/uni-ui": "^1.4.26",
50 | "ano-ui": "^0.6.3",
51 | "clipboard": "^2.0.11",
52 | "dayjs": "^1.11.7",
53 | "lodash-es": "^4.17.21",
54 | "pinia": "^2.0.32",
55 | "pinia-persistedstate-plugin": "^0.1.0",
56 | "uview-plus": "^3.1.28",
57 | "vk-uview-ui": "^1.4.4",
58 | "vue": "^3.2.47",
59 | "vue-demi": "^0.13.11",
60 | "vue-i18n": "^9.2.2"
61 | },
62 | "devDependencies": {
63 | "@antfu/eslint-config": "^0.35.2",
64 | "@dcloudio/types": "^3.2.11",
65 | "@dcloudio/uni-automator": "3.0.0-alpha-3070620230227001",
66 | "@dcloudio/uni-cli-shared": "3.0.0-alpha-3070620230227001",
67 | "@dcloudio/uni-stacktracey": "3.0.0-alpha-3070620230227001",
68 | "@dcloudio/vite-plugin-uni": "3.0.0-alpha-3070620230227001",
69 | "@iconify-json/carbon": "^1.1.15",
70 | "@iconify-json/mdi": "^1.1.47",
71 | "@iconify/json": "^2.2.30",
72 | "@rollup/plugin-commonjs": "^24.0.1",
73 | "@types/lodash-es": "^4.17.6",
74 | "@types/node": "^18.14.0",
75 | "@uni-helper/vite-plugin-uni-components": "^0.0.3",
76 | "@uni-helper/vite-plugin-uni-layouts": "^0.0.2",
77 | "@unocss/eslint-config": "^0.49.8",
78 | "@vue/tsconfig": "^0.1.3",
79 | "eslint": "^8.34.0",
80 | "jest": "^29.4.3",
81 | "postcss": "^8.4.21",
82 | "sass": "^1.58.3",
83 | "terser": "^5.16.4",
84 | "typescript": "^4.9.5",
85 | "unocss": "^0.49.8",
86 | "unocss-applet": "^0.3.2",
87 | "unplugin-auto-import": "^0.14.4",
88 | "vite": "^4.0.3",
89 | "vite-plugin-inspect": "^0.7.15"
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/src/pages/me.vue:
--------------------------------------------------------------------------------
1 |
24 |
25 |
26 |
27 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 | {{ user.nickname }}
41 |
42 |
43 | {{ user.id }}
44 |
45 |
46 | 点击登录
47 |
48 |
49 |
50 |
51 |
52 |
53 | 今日剩余次数{{ user.credit }}次
54 |
55 |
58 |
59 |
60 |
61 |
62 |
63 | 常用工具
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 | 版本号 2.0.0
83 |
84 |
85 |
86 |
87 |
88 |
128 |
--------------------------------------------------------------------------------
/src/pages/config.ts:
--------------------------------------------------------------------------------
1 | // @unocss-include
2 | const config = [
3 | {
4 | icon: 'https://cdn-icons-png.flaticon.com/512/2548/2548881.png',
5 | title: '聊天🔥',
6 | id: 'chat',
7 | desc: '支持会话上下文理解能力的智能问答聊天机器人',
8 | url: './chat/index',
9 | className: 'from-pink-500 via-red-500 to-yellow-500',
10 | },
11 | {
12 | icon: 'https://cdn-icons-png.flaticon.com/128/726/726623.png',
13 | title: '邮件助手🔥',
14 | desc: '帮你生成客套话、富有创意和引人入胜的电子邮件内容的工具',
15 | url: './generic/index',
16 | id: 'email',
17 | className: 'from-purple-600 to-pink-600',
18 | pageConfig: {
19 | inputTitle: '请输入邮件内容',
20 | placeholder: 'eg: 邀请小明周末吃饭',
21 | val: '',
22 | prompt: 'Generate a business email in {{category}} that is friendly, but still professional and appropriate for the workplace.The topic is: ',
23 | category: {
24 | title: '选择语言',
25 | val: 'Simplified Chinese',
26 | options: [
27 | { value: 'UK English', text: '英语' },
28 | { value: 'Simplified Chinese', text: '中文' },
29 | { value: 'Japanese', text: '日语' },
30 | { value: 'Italiano', text: '意大利语' },
31 | { value: 'Español', text: '西班牙语' },
32 | { value: 'French', text: '法语' },
33 | { value: '한국어', text: '韩语' },
34 | ],
35 | },
36 | },
37 | },
38 | // {
39 | // icon: 'https://cdn-icons-png.flaticon.com/512/3658/3658959.png',
40 | // title: '电影推荐',
41 | // desc: '一个为您的电子邮件生成富有创意和引人入胜的主题行的工具,帮助您获得更多的打开和点击!',
42 | // url: './generic/index',
43 | // id: 'movie',
44 | // className: 'from-green-300 via-blue-500 to-purple-600',
45 | // },
46 | {
47 | icon: 'https://img.88icon.com/download/jpg/20200905/fbfdcc6d503f999641bec1d4b40869ca_512_512.jpg!88bg',
48 | title: '日周报生成🔥',
49 | desc: '输入工作内容,小助手帮你快速完成周报',
50 | url: './generic/index',
51 | id: 'weekly',
52 | className: 'from-purple-200 via-purple-400 to-purple-800',
53 | pageConfig: {
54 | inputTitle: '请输入日报内容',
55 | placeholder: 'eg: 完成了扫码登录功能,优化了UI',
56 | val: '',
57 | prompt: '围绕以下工作内容,帮我补充并写个工作的{{category}},内容+列表的形式: ',
58 | category: {
59 | title: '选择类型',
60 | val: '日报',
61 | options: [
62 | { value: '日报', text: '日报' },
63 | { value: '周报', text: '周报' },
64 | { value: '月报', text: '月报' },
65 | ],
66 | },
67 | },
68 | },
69 | {
70 | icon: 'https://cos.codefe.top/images/xiaohongshu_icon.png',
71 | title: '小红书风格模拟器',
72 | desc: '输入你想发布的内容,帮你生成小红书的风格 ',
73 | url: './generic/index',
74 | id: 'xiaohongshu',
75 | className: 'from-purple-400 to-yellow-400',
76 | pageConfig: {
77 | inputTitle: '输入文案',
78 | placeholder: 'eg: 安利一款迪奥口红',
79 | val: '',
80 | prompt: '帮我扩展一下这段文字,起一个能吸引眼球的标题,内容润色成小红书的风格,每行开头都用不同的emoji: ',
81 | },
82 | },
83 | {
84 | icon: 'http://up.54fcnr.com/pic_source/04/40/00/044000bea092cdd4c5ed64d53d161730.gif',
85 | title: '哄女友小助手',
86 | desc: '请输入女朋友生气的原因,小助手为你生成一段道歉的话',
87 | url: './generic/index',
88 | id: 'apology',
89 | className: 'from-green-300 to-purple-400',
90 | pageConfig: {
91 | inputTitle: '请输入内容',
92 | placeholder: 'eg: 情人节没买礼物',
93 | val: '',
94 | prompt: '针对下面惹女朋友生气的原因,生成一段用于对女盆友道歉的文案,态度要表现的诚恳: ',
95 | },
96 | },
97 | {
98 | icon: 'https://cdn-icons-png.flaticon.com/512/2917/2917633.png',
99 | title: '食谱推荐',
100 | desc: '一种工具,可帮助您根据厨房中已有的食材查找食谱',
101 | url: './generic/index',
102 | id: 'recipe',
103 | className: 'from-fuchsia-500 via-red-600 to-orange-400',
104 | pageConfig: {
105 | inputTitle: '请输入食材',
106 | placeholder: 'eg: 土豆、胡萝卜',
107 | val: '',
108 | prompt: '针对下面这些食材,请帮我推荐一份合适的菜谱,并说出具体烹饪方法,风格是{{category}}: ',
109 | category: {
110 | title: '选择类型',
111 | val: '中餐',
112 | options: [
113 | { value: '中餐', text: '中餐' },
114 | { value: '西餐', text: '西餐' },
115 | ],
116 | },
117 | },
118 | },
119 | {
120 | icon: 'https://cos.codefe.top/images/roadblock.png',
121 | title: '施工中',
122 | desc: '敬请期待',
123 | url: './generic/index',
124 | id: 'recipe',
125 | className: 'bg-gray-400',
126 | status: 0,
127 | },
128 | ]
129 | export default config
130 |
--------------------------------------------------------------------------------
/src/pages/chat/index.vue:
--------------------------------------------------------------------------------
1 |
81 |
82 |
83 |
84 |
85 |
89 |
90 |
97 |
98 |
104 |
105 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 |
119 | 提示: 点击消息框即可复制
120 |
121 |
122 |
123 |
126 |
129 |
130 |
131 |
132 |
133 |
144 |
--------------------------------------------------------------------------------
/src/auto-imports.d.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | /* prettier-ignore */
3 | // @ts-nocheck
4 | // Generated by unplugin-auto-import
5 | export {}
6 | declare global {
7 | const EffectScope: typeof import('vue')['EffectScope']
8 | const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
9 | const computed: typeof import('vue')['computed']
10 | const createApp: typeof import('vue')['createApp']
11 | const createPinia: typeof import('pinia')['createPinia']
12 | const customRef: typeof import('vue')['customRef']
13 | const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
14 | const defineComponent: typeof import('vue')['defineComponent']
15 | const defineStore: typeof import('pinia')['defineStore']
16 | const effectScope: typeof import('vue')['effectScope']
17 | const getActivePinia: typeof import('pinia')['getActivePinia']
18 | const getCurrentInstance: typeof import('vue')['getCurrentInstance']
19 | const getCurrentScope: typeof import('vue')['getCurrentScope']
20 | const h: typeof import('vue')['h']
21 | const inject: typeof import('vue')['inject']
22 | const isProxy: typeof import('vue')['isProxy']
23 | const isReactive: typeof import('vue')['isReactive']
24 | const isReadonly: typeof import('vue')['isReadonly']
25 | const isRef: typeof import('vue')['isRef']
26 | const mapActions: typeof import('pinia')['mapActions']
27 | const mapGetters: typeof import('pinia')['mapGetters']
28 | const mapState: typeof import('pinia')['mapState']
29 | const mapStores: typeof import('pinia')['mapStores']
30 | const mapWritableState: typeof import('pinia')['mapWritableState']
31 | const markRaw: typeof import('vue')['markRaw']
32 | const nextTick: typeof import('vue')['nextTick']
33 | const onActivated: typeof import('vue')['onActivated']
34 | const onAddToFavorites: typeof import('@dcloudio/uni-app')['onAddToFavorites']
35 | const onBackPress: typeof import('@dcloudio/uni-app')['onBackPress']
36 | const onBeforeMount: typeof import('vue')['onBeforeMount']
37 | const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
38 | const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
39 | const onDeactivated: typeof import('vue')['onDeactivated']
40 | const onError: typeof import('@dcloudio/uni-app')['onError']
41 | const onErrorCaptured: typeof import('vue')['onErrorCaptured']
42 | const onHide: typeof import('@dcloudio/uni-app')['onHide']
43 | const onLaunch: typeof import('@dcloudio/uni-app')['onLaunch']
44 | const onLoad: typeof import('@dcloudio/uni-app')['onLoad']
45 | const onMounted: typeof import('vue')['onMounted']
46 | const onNavigationBarButtonTap: typeof import('@dcloudio/uni-app')['onNavigationBarButtonTap']
47 | const onNavigationBarSearchInputChanged: typeof import('@dcloudio/uni-app')['onNavigationBarSearchInputChanged']
48 | const onNavigationBarSearchInputClicked: typeof import('@dcloudio/uni-app')['onNavigationBarSearchInputClicked']
49 | const onNavigationBarSearchInputConfirmed: typeof import('@dcloudio/uni-app')['onNavigationBarSearchInputConfirmed']
50 | const onNavigationBarSearchInputFocusChanged: typeof import('@dcloudio/uni-app')['onNavigationBarSearchInputFocusChanged']
51 | const onPageNotFound: typeof import('@dcloudio/uni-app')['onPageNotFound']
52 | const onPageScroll: typeof import('@dcloudio/uni-app')['onPageScroll']
53 | const onPullDownRefresh: typeof import('@dcloudio/uni-app')['onPullDownRefresh']
54 | const onReachBottom: typeof import('@dcloudio/uni-app')['onReachBottom']
55 | const onReady: typeof import('@dcloudio/uni-app')['onReady']
56 | const onRenderTracked: typeof import('vue')['onRenderTracked']
57 | const onRenderTriggered: typeof import('vue')['onRenderTriggered']
58 | const onResize: typeof import('@dcloudio/uni-app')['onResize']
59 | const onScopeDispose: typeof import('vue')['onScopeDispose']
60 | const onServerPrefetch: typeof import('vue')['onServerPrefetch']
61 | const onShareAppMessage: typeof import('@dcloudio/uni-app')['onShareAppMessage']
62 | const onShareTimeline: typeof import('@dcloudio/uni-app')['onShareTimeline']
63 | const onShow: typeof import('@dcloudio/uni-app')['onShow']
64 | const onTabItemTap: typeof import('@dcloudio/uni-app')['onTabItemTap']
65 | const onThemeChange: typeof import('@dcloudio/uni-app')['onThemeChange']
66 | const onUnhandledRejection: typeof import('@dcloudio/uni-app')['onUnhandledRejection']
67 | const onUnload: typeof import('@dcloudio/uni-app')['onUnload']
68 | const onUnmounted: typeof import('vue')['onUnmounted']
69 | const onUpdated: typeof import('vue')['onUpdated']
70 | const provide: typeof import('vue')['provide']
71 | const reactive: typeof import('vue')['reactive']
72 | const readonly: typeof import('vue')['readonly']
73 | const ref: typeof import('vue')['ref']
74 | const resolveComponent: typeof import('vue')['resolveComponent']
75 | const setActivePinia: typeof import('pinia')['setActivePinia']
76 | const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix']
77 | const setupPinia: typeof import('./stores/index')['setupPinia']
78 | const shallowReactive: typeof import('vue')['shallowReactive']
79 | const shallowReadonly: typeof import('vue')['shallowReadonly']
80 | const shallowRef: typeof import('vue')['shallowRef']
81 | const storeToRefs: typeof import('pinia')['storeToRefs']
82 | const stores: typeof import('./stores/index')['default']
83 | const toRaw: typeof import('vue')['toRaw']
84 | const toRef: typeof import('vue')['toRef']
85 | const toRefs: typeof import('vue')['toRefs']
86 | const triggerRef: typeof import('vue')['triggerRef']
87 | const unref: typeof import('vue')['unref']
88 | const useAttrs: typeof import('vue')['useAttrs']
89 | const useChatgpt: typeof import('./composables/useChatgpt')['useChatgpt']
90 | const useCssModule: typeof import('vue')['useCssModule']
91 | const useCssVars: typeof import('vue')['useCssVars']
92 | const useShare: typeof import('./composables/useShare')['useShare']
93 | const useSlots: typeof import('vue')['useSlots']
94 | const user: typeof import('./stores/user')['default']
95 | const watch: typeof import('vue')['watch']
96 | const watchEffect: typeof import('vue')['watchEffect']
97 | const watchPostEffect: typeof import('vue')['watchPostEffect']
98 | const watchSyncEffect: typeof import('vue')['watchSyncEffect']
99 | }
100 | // for type re-export
101 | declare global {
102 | // @ts-ignore
103 | export type { Component,ComponentPublicInstance,ComputedRef,InjectionKey,PropType,Ref,VNode } from 'vue'
104 | }
105 | // for vue template auto import
106 | import { UnwrapRef } from 'vue'
107 | declare module 'vue' {
108 | interface ComponentCustomProperties {
109 | readonly EffectScope: UnwrapRef
110 | readonly acceptHMRUpdate: UnwrapRef
111 | readonly computed: UnwrapRef
112 | readonly createApp: UnwrapRef
113 | readonly createPinia: UnwrapRef
114 | readonly customRef: UnwrapRef
115 | readonly defineAsyncComponent: UnwrapRef
116 | readonly defineComponent: UnwrapRef
117 | readonly defineStore: UnwrapRef
118 | readonly effectScope: UnwrapRef
119 | readonly getActivePinia: UnwrapRef
120 | readonly getCurrentInstance: UnwrapRef
121 | readonly getCurrentScope: UnwrapRef
122 | readonly h: UnwrapRef
123 | readonly inject: UnwrapRef
124 | readonly isProxy: UnwrapRef
125 | readonly isReactive: UnwrapRef
126 | readonly isReadonly: UnwrapRef
127 | readonly isRef: UnwrapRef
128 | readonly mapActions: UnwrapRef
129 | readonly mapGetters: UnwrapRef
130 | readonly mapState: UnwrapRef
131 | readonly mapStores: UnwrapRef
132 | readonly mapWritableState: UnwrapRef
133 | readonly markRaw: UnwrapRef
134 | readonly nextTick: UnwrapRef
135 | readonly onActivated: UnwrapRef
136 | readonly onAddToFavorites: UnwrapRef
137 | readonly onBackPress: UnwrapRef
138 | readonly onBeforeMount: UnwrapRef
139 | readonly onBeforeUnmount: UnwrapRef
140 | readonly onBeforeUpdate: UnwrapRef
141 | readonly onDeactivated: UnwrapRef
142 | readonly onError: UnwrapRef
143 | readonly onErrorCaptured: UnwrapRef
144 | readonly onHide: UnwrapRef
145 | readonly onLaunch: UnwrapRef
146 | readonly onLoad: UnwrapRef
147 | readonly onMounted: UnwrapRef
148 | readonly onNavigationBarButtonTap: UnwrapRef
149 | readonly onNavigationBarSearchInputChanged: UnwrapRef
150 | readonly onNavigationBarSearchInputClicked: UnwrapRef
151 | readonly onNavigationBarSearchInputConfirmed: UnwrapRef
152 | readonly onNavigationBarSearchInputFocusChanged: UnwrapRef
153 | readonly onPageNotFound: UnwrapRef
154 | readonly onPageScroll: UnwrapRef
155 | readonly onPullDownRefresh: UnwrapRef
156 | readonly onReachBottom: UnwrapRef
157 | readonly onReady: UnwrapRef
158 | readonly onRenderTracked: UnwrapRef
159 | readonly onRenderTriggered: UnwrapRef
160 | readonly onResize: UnwrapRef
161 | readonly onScopeDispose: UnwrapRef
162 | readonly onServerPrefetch: UnwrapRef
163 | readonly onShareAppMessage: UnwrapRef
164 | readonly onShareTimeline: UnwrapRef
165 | readonly onShow: UnwrapRef
166 | readonly onTabItemTap: UnwrapRef
167 | readonly onThemeChange: UnwrapRef
168 | readonly onUnhandledRejection: UnwrapRef
169 | readonly onUnload: UnwrapRef
170 | readonly onUnmounted: UnwrapRef
171 | readonly onUpdated: UnwrapRef
172 | readonly provide: UnwrapRef
173 | readonly reactive: UnwrapRef
174 | readonly readonly: UnwrapRef
175 | readonly ref: UnwrapRef
176 | readonly resolveComponent: UnwrapRef
177 | readonly setActivePinia: UnwrapRef
178 | readonly setMapStoreSuffix: UnwrapRef
179 | readonly setupPinia: UnwrapRef
180 | readonly shallowReactive: UnwrapRef
181 | readonly shallowReadonly: UnwrapRef
182 | readonly shallowRef: UnwrapRef
183 | readonly storeToRefs: UnwrapRef
184 | readonly stores: UnwrapRef
185 | readonly toRaw: UnwrapRef
186 | readonly toRef: UnwrapRef
187 | readonly toRefs: UnwrapRef
188 | readonly triggerRef: UnwrapRef
189 | readonly unref: UnwrapRef
190 | readonly useAttrs: UnwrapRef
191 | readonly useChatgpt: UnwrapRef
192 | readonly useCssModule: UnwrapRef
193 | readonly useCssVars: UnwrapRef
194 | readonly useShare: UnwrapRef
195 | readonly useSlots: UnwrapRef
196 | readonly user: UnwrapRef
197 | readonly watch: UnwrapRef
198 | readonly watchEffect: UnwrapRef
199 | readonly watchPostEffect: UnwrapRef
200 | readonly watchSyncEffect: UnwrapRef
201 | }
202 | }
203 |
--------------------------------------------------------------------------------
/src/components/mp-html/mp-html.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
482 |
483 |
499 |
--------------------------------------------------------------------------------
/src/components/mp-html/node/node.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 | {{n.text}}
24 |
25 |
26 | {{n.text}}
27 |
28 | \n
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
108 |
405 |
--------------------------------------------------------------------------------
/src/components/mp-html/parser.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @fileoverview html 解析器
3 | */
4 |
5 | // 配置
6 | const config = {
7 | // 信任的标签(保持标签名不变)
8 | trustTags: makeMap('a,abbr,ad,audio,b,blockquote,br,code,col,colgroup,dd,del,dl,dt,div,em,fieldset,h1,h2,h3,h4,h5,h6,hr,i,img,ins,label,legend,li,ol,p,q,ruby,rt,source,span,strong,sub,sup,table,tbody,td,tfoot,th,thead,tr,title,ul,video'),
9 |
10 | // 块级标签(转为 div,其他的非信任标签转为 span)
11 | blockTags: makeMap('address,article,aside,body,caption,center,cite,footer,header,html,nav,pre,section'),
12 |
13 | // #ifdef (MP-WEIXIN || MP-QQ || APP-PLUS || MP-360) && VUE3
14 | // 行内标签
15 | inlineTags: makeMap('abbr,b,big,code,del,em,i,ins,label,q,small,span,strong,sub,sup'),
16 | // #endif
17 |
18 | // 要移除的标签
19 | ignoreTags: makeMap('area,base,canvas,embed,frame,head,iframe,input,link,map,meta,param,rp,script,source,style,textarea,title,track,wbr'),
20 |
21 | // 自闭合的标签
22 | voidTags: makeMap('area,base,br,col,circle,ellipse,embed,frame,hr,img,input,line,link,meta,param,path,polygon,rect,source,track,use,wbr'),
23 |
24 | // html 实体
25 | entities: {
26 | lt: '<',
27 | gt: '>',
28 | quot: '"',
29 | apos: "'",
30 | ensp: '\u2002',
31 | emsp: '\u2003',
32 | nbsp: '\xA0',
33 | semi: ';',
34 | ndash: '–',
35 | mdash: '—',
36 | middot: '·',
37 | lsquo: '‘',
38 | rsquo: '’',
39 | ldquo: '“',
40 | rdquo: '”',
41 | bull: '•',
42 | hellip: '…',
43 | larr: '←',
44 | uarr: '↑',
45 | rarr: '→',
46 | darr: '↓'
47 | },
48 |
49 | // 默认的标签样式
50 | tagStyle: {
51 | // #ifndef APP-PLUS-NVUE
52 | address: 'font-style:italic',
53 | big: 'display:inline;font-size:1.2em',
54 | caption: 'display:table-caption;text-align:center',
55 | center: 'text-align:center',
56 | cite: 'font-style:italic',
57 | dd: 'margin-left:40px',
58 | mark: 'background-color:yellow',
59 | pre: 'font-family:monospace;white-space:pre',
60 | s: 'text-decoration:line-through',
61 | small: 'display:inline;font-size:0.8em',
62 | strike: 'text-decoration:line-through',
63 | u: 'text-decoration:underline'
64 | // #endif
65 | },
66 |
67 | // svg 大小写对照表
68 | svgDict: {
69 | animatetransform: 'animateTransform',
70 | lineargradient: 'linearGradient',
71 | viewbox: 'viewBox',
72 | attributename: 'attributeName',
73 | repeatcount: 'repeatCount',
74 | repeatdur: 'repeatDur'
75 | }
76 | }
77 | const tagSelector={}
78 | const {
79 | windowWidth,
80 | // #ifdef MP-WEIXIN
81 | system
82 | // #endif
83 | } = uni.getSystemInfoSync()
84 | const blankChar = makeMap(' ,\r,\n,\t,\f')
85 | let idIndex = 0
86 |
87 | // #ifdef H5 || APP-PLUS
88 | config.ignoreTags.iframe = undefined
89 | config.trustTags.iframe = true
90 | config.ignoreTags.embed = undefined
91 | config.trustTags.embed = true
92 | // #endif
93 | // #ifdef APP-PLUS-NVUE
94 | config.ignoreTags.source = undefined
95 | config.ignoreTags.style = undefined
96 | // #endif
97 |
98 | /**
99 | * @description 创建 map
100 | * @param {String} str 逗号分隔
101 | */
102 | function makeMap (str) {
103 | const map = Object.create(null)
104 | const list = str.split(',')
105 | for (let i = list.length; i--;) {
106 | map[list[i]] = true
107 | }
108 | return map
109 | }
110 |
111 | /**
112 | * @description 解码 html 实体
113 | * @param {String} str 要解码的字符串
114 | * @param {Boolean} amp 要不要解码 &
115 | * @returns {String} 解码后的字符串
116 | */
117 | function decodeEntity (str, amp) {
118 | let i = str.indexOf('&')
119 | while (i !== -1) {
120 | const j = str.indexOf(';', i + 3)
121 | let code
122 | if (j === -1) break
123 | if (str[i + 1] === '#') {
124 | // { 形式的实体
125 | code = parseInt((str[i + 2] === 'x' ? '0' : '') + str.substring(i + 2, j))
126 | if (!isNaN(code)) {
127 | str = str.substr(0, i) + String.fromCharCode(code) + str.substr(j + 1)
128 | }
129 | } else {
130 | // 形式的实体
131 | code = str.substring(i + 1, j)
132 | if (config.entities[code] || (code === 'amp' && amp)) {
133 | str = str.substr(0, i) + (config.entities[code] || '&') + str.substr(j + 1)
134 | }
135 | }
136 | i = str.indexOf('&', i + 1)
137 | }
138 | return str
139 | }
140 |
141 | /**
142 | * @description 合并多个块级标签,加快长内容渲染
143 | * @param {Array} nodes 要合并的标签数组
144 | */
145 | function mergeNodes (nodes) {
146 | let i = nodes.length - 1
147 | for (let j = i; j >= -1; j--) {
148 | if (j === -1 || nodes[j].c || !nodes[j].name || (nodes[j].name !== 'div' && nodes[j].name !== 'p' && nodes[j].name[0] !== 'h') || (nodes[j].attrs.style || '').includes('inline')) {
149 | if (i - j >= 5) {
150 | nodes.splice(j + 1, i - j, {
151 | name: 'div',
152 | attrs: {},
153 | children: nodes.slice(j + 1, i + 1)
154 | })
155 | }
156 | i = j - 1
157 | }
158 | }
159 | }
160 |
161 | /**
162 | * @description html 解析器
163 | * @param {Object} vm 组件实例
164 | */
165 | function Parser (vm) {
166 | this.options = vm || {}
167 | this.tagStyle = Object.assign({}, config.tagStyle, this.options.tagStyle)
168 | this.imgList = vm.imgList || []
169 | this.imgList._unloadimgs = 0
170 | this.plugins = vm.plugins || []
171 | this.attrs = Object.create(null)
172 | this.stack = []
173 | this.nodes = []
174 | this.pre = (this.options.containerStyle || '').includes('white-space') && this.options.containerStyle.includes('pre') ? 2 : 0
175 | }
176 |
177 | /**
178 | * @description 执行解析
179 | * @param {String} content 要解析的文本
180 | */
181 | Parser.prototype.parse = function (content) {
182 | // 插件处理
183 | for (let i = this.plugins.length; i--;) {
184 | if (this.plugins[i].onUpdate) {
185 | content = this.plugins[i].onUpdate(content, config) || content
186 | }
187 | }
188 |
189 | new Lexer(this).parse(content)
190 | // 出栈未闭合的标签
191 | while (this.stack.length) {
192 | this.popNode()
193 | }
194 | if (this.nodes.length > 50) {
195 | mergeNodes(this.nodes)
196 | }
197 | return this.nodes
198 | }
199 |
200 | /**
201 | * @description 将标签暴露出来(不被 rich-text 包含)
202 | */
203 | Parser.prototype.expose = function () {
204 | // #ifndef APP-PLUS-NVUE
205 | for (let i = this.stack.length; i--;) {
206 | const item = this.stack[i]
207 | if (item.c || item.name === 'a' || item.name === 'video' || item.name === 'audio') return
208 | item.c = 1
209 | }
210 | // #endif
211 | }
212 |
213 | /**
214 | * @description 处理插件
215 | * @param {Object} node 要处理的标签
216 | * @returns {Boolean} 是否要移除此标签
217 | */
218 | Parser.prototype.hook = function (node) {
219 | for (let i = this.plugins.length; i--;) {
220 | if (this.plugins[i].onParse && this.plugins[i].onParse(node, this) === false) {
221 | return false
222 | }
223 | }
224 | return true
225 | }
226 |
227 | /**
228 | * @description 将链接拼接上主域名
229 | * @param {String} url 需要拼接的链接
230 | * @returns {String} 拼接后的链接
231 | */
232 | Parser.prototype.getUrl = function (url) {
233 | const domain = this.options.domain
234 | if (url[0] === '/') {
235 | if (url[1] === '/') {
236 | // // 开头的补充协议名
237 | url = (domain ? domain.split('://')[0] : 'http') + ':' + url
238 | } else if (domain) {
239 | // 否则补充整个域名
240 | url = domain + url
241 | } /* #ifdef APP-PLUS */ else {
242 | url = plus.io.convertLocalFileSystemURL(url)
243 | } /* #endif */
244 | } else if (!url.includes('data:') && !url.includes('://')) {
245 | if (domain) {
246 | url = domain + '/' + url
247 | } /* #ifdef APP-PLUS */ else {
248 | url = plus.io.convertLocalFileSystemURL(url)
249 | } /* #endif */
250 | }
251 | return url
252 | }
253 |
254 | /**
255 | * @description 解析样式表
256 | * @param {Object} node 标签
257 | * @returns {Object}
258 | */
259 | Parser.prototype.parseStyle = function (node) {
260 | const attrs = node.attrs
261 | const list = (this.tagStyle[node.name] || '').split(';').concat((attrs.style || '').split(';'))
262 | const styleObj = {}
263 | let tmp = ''
264 |
265 | if (attrs.id && !this.xml) {
266 | // 暴露锚点
267 | if (this.options.useAnchor) {
268 | this.expose()
269 | } else if (node.name !== 'img' && node.name !== 'a' && node.name !== 'video' && node.name !== 'audio') {
270 | attrs.id = undefined
271 | }
272 | }
273 |
274 | // 转换 width 和 height 属性
275 | if (attrs.width) {
276 | styleObj.width = parseFloat(attrs.width) + (attrs.width.includes('%') ? '%' : 'px')
277 | attrs.width = undefined
278 | }
279 | if (attrs.height) {
280 | styleObj.height = parseFloat(attrs.height) + (attrs.height.includes('%') ? '%' : 'px')
281 | attrs.height = undefined
282 | }
283 |
284 | for (let i = 0, len = list.length; i < len; i++) {
285 | const info = list[i].split(':')
286 | if (info.length < 2) continue
287 | const key = info.shift().trim().toLowerCase()
288 | let value = info.join(':').trim()
289 | if ((value[0] === '-' && value.lastIndexOf('-') > 0) || value.includes('safe')) {
290 | // 兼容性的 css 不压缩
291 | tmp += `;${key}:${value}`
292 | } else if (!styleObj[key] || value.includes('import') || !styleObj[key].includes('import')) {
293 | // 重复的样式进行覆盖
294 | if (value.includes('url')) {
295 | // 填充链接
296 | let j = value.indexOf('(') + 1
297 | if (j) {
298 | while (value[j] === '"' || value[j] === "'" || blankChar[value[j]]) {
299 | j++
300 | }
301 | value = value.substr(0, j) + this.getUrl(value.substr(j))
302 | }
303 | } else if (value.includes('rpx')) {
304 | // 转换 rpx(rich-text 内部不支持 rpx)
305 | value = value.replace(/[0-9.]+\s*rpx/g, $ => parseFloat($) * windowWidth / 750 + 'px')
306 | }
307 | styleObj[key] = value
308 | }
309 | }
310 |
311 | node.attrs.style = tmp
312 | return styleObj
313 | }
314 |
315 | /**
316 | * @description 解析到标签名
317 | * @param {String} name 标签名
318 | * @private
319 | */
320 | Parser.prototype.onTagName = function (name) {
321 | this.tagName = this.xml ? name : name.toLowerCase()
322 | if (this.tagName === 'svg') {
323 | this.xml = (this.xml || 0) + 1 // svg 标签内大小写敏感
324 | }
325 | }
326 |
327 | /**
328 | * @description 解析到属性名
329 | * @param {String} name 属性名
330 | * @private
331 | */
332 | Parser.prototype.onAttrName = function (name) {
333 | name = this.xml ? name : name.toLowerCase()
334 | if (name.substr(0, 5) === 'data-') {
335 | if (name === 'data-src' && !this.attrs.src) {
336 | // data-src 自动转为 src
337 | this.attrName = 'src'
338 | } else if (this.tagName === 'img' || this.tagName === 'a') {
339 | // a 和 img 标签保留 data- 的属性,可以在 imgtap 和 linktap 事件中使用
340 | this.attrName = name
341 | } else {
342 | // 剩余的移除以减小大小
343 | this.attrName = undefined
344 | }
345 | } else {
346 | this.attrName = name
347 | this.attrs[name] = 'T' // boolean 型属性缺省设置
348 | }
349 | }
350 |
351 | /**
352 | * @description 解析到属性值
353 | * @param {String} val 属性值
354 | * @private
355 | */
356 | Parser.prototype.onAttrVal = function (val) {
357 | const name = this.attrName || ''
358 | if (name === 'style' || name === 'href') {
359 | // 部分属性进行实体解码
360 | this.attrs[name] = decodeEntity(val, true)
361 | } else if (name.includes('src')) {
362 | // 拼接主域名
363 | this.attrs[name] = this.getUrl(decodeEntity(val, true))
364 | } else if (name) {
365 | this.attrs[name] = val
366 | }
367 | }
368 |
369 | /**
370 | * @description 解析到标签开始
371 | * @param {Boolean} selfClose 是否有自闭合标识 />
372 | * @private
373 | */
374 | Parser.prototype.onOpenTag = function (selfClose) {
375 | // 拼装 node
376 | const node = Object.create(null)
377 | node.name = this.tagName
378 | node.attrs = this.attrs
379 | // 避免因为自动 diff 使得 type 被设置为 null 导致部分内容不显示
380 | if (this.options.nodes.length) {
381 | node.type = 'node'
382 | }
383 | this.attrs = Object.create(null)
384 |
385 | const attrs = node.attrs
386 | const parent = this.stack[this.stack.length - 1]
387 | const siblings = parent ? parent.children : this.nodes
388 | const close = this.xml ? selfClose : config.voidTags[node.name]
389 |
390 | // 替换标签名选择器
391 | if (tagSelector[node.name]) {
392 | attrs.class = tagSelector[node.name] + (attrs.class ? ' ' + attrs.class : '')
393 | }
394 |
395 | // 转换 embed 标签
396 | if (node.name === 'embed') {
397 | // #ifndef H5 || APP-PLUS
398 | const src = attrs.src || ''
399 | // 按照后缀名和 type 将 embed 转为 video 或 audio
400 | if (src.includes('.mp4') || src.includes('.3gp') || src.includes('.m3u8') || (attrs.type || '').includes('video')) {
401 | node.name = 'video'
402 | } else if (src.includes('.mp3') || src.includes('.wav') || src.includes('.aac') || src.includes('.m4a') || (attrs.type || '').includes('audio')) {
403 | node.name = 'audio'
404 | }
405 | if (attrs.autostart) {
406 | attrs.autoplay = 'T'
407 | }
408 | attrs.controls = 'T'
409 | // #endif
410 | // #ifdef H5 || APP-PLUS
411 | this.expose()
412 | // #endif
413 | }
414 |
415 | // #ifndef APP-PLUS-NVUE
416 | // 处理音视频
417 | if (node.name === 'video' || node.name === 'audio') {
418 | // 设置 id 以便获取 context
419 | if (node.name === 'video' && !attrs.id) {
420 | attrs.id = 'v' + idIndex++
421 | }
422 | // 没有设置 controls 也没有设置 autoplay 的自动设置 controls
423 | if (!attrs.controls && !attrs.autoplay) {
424 | attrs.controls = 'T'
425 | }
426 | // 用数组存储所有可用的 source
427 | node.src = []
428 | if (attrs.src) {
429 | node.src.push(attrs.src)
430 | attrs.src = undefined
431 | }
432 | this.expose()
433 | }
434 | // #endif
435 |
436 | // 处理自闭合标签
437 | if (close) {
438 | if (!this.hook(node) || config.ignoreTags[node.name]) {
439 | // 通过 base 标签设置主域名
440 | if (node.name === 'base' && !this.options.domain) {
441 | this.options.domain = attrs.href
442 | } /* #ifndef APP-PLUS-NVUE */ else if (node.name === 'source' && parent && (parent.name === 'video' || parent.name === 'audio') && attrs.src) {
443 | // 设置 source 标签(仅父节点为 video 或 audio 时有效)
444 | parent.src.push(attrs.src)
445 | } /* #endif */
446 | return
447 | }
448 |
449 | // 解析 style
450 | const styleObj = this.parseStyle(node)
451 |
452 | // 处理图片
453 | if (node.name === 'img') {
454 | if (attrs.src) {
455 | // 标记 webp
456 | if (attrs.src.includes('webp')) {
457 | node.webp = 'T'
458 | }
459 | // data url 图片如果没有设置 original-src 默认为不可预览的小图片
460 | if (attrs.src.includes('data:') && !attrs['original-src']) {
461 | attrs.ignore = 'T'
462 | }
463 | if (!attrs.ignore || node.webp || attrs.src.includes('cloud://')) {
464 | for (let i = this.stack.length; i--;) {
465 | const item = this.stack[i]
466 | if (item.name === 'a') {
467 | node.a = item.attrs
468 | }
469 | if (item.name === 'table' && !node.webp && !attrs.src.includes('cloud://')) {
470 | if (!styleObj.display || styleObj.display.includes('inline')) {
471 | node.t = 'inline-block'
472 | } else {
473 | node.t = styleObj.display
474 | }
475 | styleObj.display = undefined
476 | }
477 | // #ifndef H5 || APP-PLUS
478 | const style = item.attrs.style || ''
479 | if (style.includes('flex:') && !style.includes('flex:0') && !style.includes('flex: 0') && (!styleObj.width || parseInt(styleObj.width) > 100)) {
480 | styleObj.width = '100% !important'
481 | styleObj.height = ''
482 | for (let j = i + 1; j < this.stack.length; j++) {
483 | this.stack[j].attrs.style = (this.stack[j].attrs.style || '').replace('inline-', '')
484 | }
485 | } else if (style.includes('flex') && styleObj.width === '100%') {
486 | for (let j = i + 1; j < this.stack.length; j++) {
487 | const style = this.stack[j].attrs.style || ''
488 | if (!style.includes(';width') && !style.includes(' width') && style.indexOf('width') !== 0) {
489 | styleObj.width = ''
490 | break
491 | }
492 | }
493 | } else if (style.includes('inline-block')) {
494 | if (styleObj.width && styleObj.width[styleObj.width.length - 1] === '%') {
495 | item.attrs.style += ';max-width:' + styleObj.width
496 | styleObj.width = ''
497 | } else {
498 | item.attrs.style += ';max-width:100%'
499 | }
500 | }
501 | // #endif
502 | item.c = 1
503 | }
504 | attrs.i = this.imgList.length.toString()
505 | let src = attrs['original-src'] || attrs.src
506 | // #ifndef H5 || MP-ALIPAY || APP-PLUS || MP-360
507 | if (this.imgList.includes(src)) {
508 | // 如果有重复的链接则对域名进行随机大小写变换避免预览时错位
509 | let i = src.indexOf('://')
510 | if (i !== -1) {
511 | i += 3
512 | let newSrc = src.substr(0, i)
513 | for (; i < src.length; i++) {
514 | if (src[i] === '/') break
515 | newSrc += Math.random() > 0.5 ? src[i].toUpperCase() : src[i]
516 | }
517 | newSrc += src.substr(i)
518 | src = newSrc
519 | }
520 | }
521 | // #endif
522 | this.imgList.push(src)
523 | if (!node.t) {
524 | this.imgList._unloadimgs += 1
525 | }
526 | // #ifdef H5 || APP-PLUS
527 | if (this.options.lazyLoad) {
528 | attrs['data-src'] = attrs.src
529 | attrs.src = undefined
530 | }
531 | // #endif
532 | }
533 | }
534 | if (styleObj.display === 'inline') {
535 | styleObj.display = ''
536 | }
537 | // #ifndef APP-PLUS-NVUE
538 | if (attrs.ignore) {
539 | styleObj['max-width'] = styleObj['max-width'] || '100%'
540 | attrs.style += ';-webkit-touch-callout:none'
541 | }
542 | // #endif
543 | // 设置的宽度超出屏幕,为避免变形,高度转为自动
544 | if (parseInt(styleObj.width) > windowWidth) {
545 | styleObj.height = undefined
546 | }
547 | // 记录是否设置了宽高
548 | if (!isNaN(parseInt(styleObj.width))) {
549 | node.w = 'T'
550 | }
551 | if (!isNaN(parseInt(styleObj.height)) && (!styleObj.height.includes('%') || (parent && (parent.attrs.style || '').includes('height')))) {
552 | node.h = 'T'
553 | }
554 | } else if (node.name === 'svg') {
555 | siblings.push(node)
556 | this.stack.push(node)
557 | this.popNode()
558 | return
559 | }
560 | for (const key in styleObj) {
561 | if (styleObj[key]) {
562 | attrs.style += `;${key}:${styleObj[key].replace(' !important', '')}`
563 | }
564 | }
565 | attrs.style = attrs.style.substr(1) || undefined
566 | // #ifdef (MP-WEIXIN || MP-QQ) && VUE3
567 | if (!attrs.style) {
568 | delete attrs.style
569 | }
570 | // #endif
571 | } else {
572 | if ((node.name === 'pre' || ((attrs.style || '').includes('white-space') && attrs.style.includes('pre'))) && this.pre !== 2) {
573 | this.pre = node.pre = 1
574 | }
575 | node.children = []
576 | this.stack.push(node)
577 | }
578 |
579 | // 加入节点树
580 | siblings.push(node)
581 | }
582 |
583 | /**
584 | * @description 解析到标签结束
585 | * @param {String} name 标签名
586 | * @private
587 | */
588 | Parser.prototype.onCloseTag = function (name) {
589 | // 依次出栈到匹配为止
590 | name = this.xml ? name : name.toLowerCase()
591 | let i
592 | for (i = this.stack.length; i--;) {
593 | if (this.stack[i].name === name) break
594 | }
595 | if (i !== -1) {
596 | while (this.stack.length > i) {
597 | this.popNode()
598 | }
599 | } else if (name === 'p' || name === 'br') {
600 | const siblings = this.stack.length ? this.stack[this.stack.length - 1].children : this.nodes
601 | siblings.push({
602 | name,
603 | attrs: {
604 | class: tagSelector[name] || '',
605 | style: this.tagStyle[name] || ''
606 | }
607 | })
608 | }
609 | }
610 |
611 | /**
612 | * @description 处理标签出栈
613 | * @private
614 | */
615 | Parser.prototype.popNode = function () {
616 | const node = this.stack.pop()
617 | let attrs = node.attrs
618 | const children = node.children
619 | const parent = this.stack[this.stack.length - 1]
620 | const siblings = parent ? parent.children : this.nodes
621 |
622 | if (!this.hook(node) || config.ignoreTags[node.name]) {
623 | // 获取标题
624 | if (node.name === 'title' && children.length && children[0].type === 'text' && this.options.setTitle) {
625 | uni.setNavigationBarTitle({
626 | title: children[0].text
627 | })
628 | }
629 | siblings.pop()
630 | return
631 | }
632 |
633 | if (node.pre && this.pre !== 2) {
634 | // 是否合并空白符标识
635 | this.pre = node.pre = undefined
636 | for (let i = this.stack.length; i--;) {
637 | if (this.stack[i].pre) {
638 | this.pre = 1
639 | }
640 | }
641 | }
642 |
643 | const styleObj = {}
644 |
645 | // 转换 svg
646 | if (node.name === 'svg') {
647 | if (this.xml > 1) {
648 | // 多层 svg 嵌套
649 | this.xml--
650 | return
651 | }
652 | // #ifdef APP-PLUS-NVUE
653 | (function traversal (node) {
654 | if (node.name) {
655 | // 调整 svg 的大小写
656 | node.name = config.svgDict[node.name] || node.name
657 | for (const item in node.attrs) {
658 | if (config.svgDict[item]) {
659 | node.attrs[config.svgDict[item]] = node.attrs[item]
660 | node.attrs[item] = undefined
661 | }
662 | }
663 | for (let i = 0; i < (node.children || []).length; i++) {
664 | traversal(node.children[i])
665 | }
666 | }
667 | })(node)
668 | // #endif
669 | // #ifndef APP-PLUS-NVUE
670 | let src = ''
671 | const style = attrs.style
672 | attrs.style = ''
673 | attrs.xmlns = 'http://www.w3.org/2000/svg';
674 | (function traversal (node) {
675 | if (node.type === 'text') {
676 | src += node.text
677 | return
678 | }
679 | const name = config.svgDict[node.name] || node.name
680 | src += '<' + name
681 | for (const item in node.attrs) {
682 | const val = node.attrs[item]
683 | if (val) {
684 | src += ` ${config.svgDict[item] || item}="${val}"`
685 | }
686 | }
687 | if (!node.children) {
688 | src += '/>'
689 | } else {
690 | src += '>'
691 | for (let i = 0; i < node.children.length; i++) {
692 | traversal(node.children[i])
693 | }
694 | src += '' + name + '>'
695 | }
696 | })(node)
697 | node.name = 'img'
698 | node.attrs = {
699 | src: 'data:image/svg+xml;utf8,' + src.replace(/#/g, '%23'),
700 | style,
701 | ignore: 'T'
702 | }
703 | node.children = undefined
704 | // #endif
705 | this.xml = false
706 | return
707 | }
708 |
709 | // #ifndef APP-PLUS-NVUE
710 | // 转换 align 属性
711 | if (attrs.align) {
712 | if (node.name === 'table') {
713 | if (attrs.align === 'center') {
714 | styleObj['margin-inline-start'] = styleObj['margin-inline-end'] = 'auto'
715 | } else {
716 | styleObj.float = attrs.align
717 | }
718 | } else {
719 | styleObj['text-align'] = attrs.align
720 | }
721 | attrs.align = undefined
722 | }
723 |
724 | // 转换 dir 属性
725 | if (attrs.dir) {
726 | styleObj.direction = attrs.dir
727 | attrs.dir = undefined
728 | }
729 |
730 | // 转换 font 标签的属性
731 | if (node.name === 'font') {
732 | if (attrs.color) {
733 | styleObj.color = attrs.color
734 | attrs.color = undefined
735 | }
736 | if (attrs.face) {
737 | styleObj['font-family'] = attrs.face
738 | attrs.face = undefined
739 | }
740 | if (attrs.size) {
741 | let size = parseInt(attrs.size)
742 | if (!isNaN(size)) {
743 | if (size < 1) {
744 | size = 1
745 | } else if (size > 7) {
746 | size = 7
747 | }
748 | styleObj['font-size'] = ['x-small', 'small', 'medium', 'large', 'x-large', 'xx-large', 'xxx-large'][size - 1]
749 | }
750 | attrs.size = undefined
751 | }
752 | }
753 | // #endif
754 |
755 | // 一些编辑器的自带 class
756 | if ((attrs.class || '').includes('align-center')) {
757 | styleObj['text-align'] = 'center'
758 | }
759 |
760 | Object.assign(styleObj, this.parseStyle(node))
761 |
762 | if (node.name !== 'table' && parseInt(styleObj.width) > windowWidth) {
763 | styleObj['max-width'] = '100%'
764 | styleObj['box-sizing'] = 'border-box'
765 | }
766 |
767 | // #ifndef APP-PLUS-NVUE
768 | if (config.blockTags[node.name]) {
769 | node.name = 'div'
770 | } else if (!config.trustTags[node.name] && !this.xml) {
771 | // 未知标签转为 span,避免无法显示
772 | node.name = 'span'
773 | }
774 |
775 | if (node.name === 'a' || node.name === 'ad'
776 | // #ifdef H5 || APP-PLUS
777 | || node.name === 'iframe' // eslint-disable-line
778 | // #endif
779 | ) {
780 | this.expose()
781 | } else if (node.name === 'video') {
782 | if ((styleObj.height || '').includes('auto')) {
783 | styleObj.height = undefined
784 | }
785 | /* #ifdef APP-PLUS */
786 | let str = ''
800 | node.html = str
801 | /* #endif */
802 | } else if ((node.name === 'ul' || node.name === 'ol') && node.c) {
803 | // 列表处理
804 | const types = {
805 | a: 'lower-alpha',
806 | A: 'upper-alpha',
807 | i: 'lower-roman',
808 | I: 'upper-roman'
809 | }
810 | if (types[attrs.type]) {
811 | attrs.style += ';list-style-type:' + types[attrs.type]
812 | attrs.type = undefined
813 | }
814 | for (let i = children.length; i--;) {
815 | if (children[i].name === 'li') {
816 | children[i].c = 1
817 | }
818 | }
819 | } else if (node.name === 'table') {
820 | // 表格处理
821 | // cellpadding、cellspacing、border 这几个常用表格属性需要通过转换实现
822 | let padding = parseFloat(attrs.cellpadding)
823 | let spacing = parseFloat(attrs.cellspacing)
824 | const border = parseFloat(attrs.border)
825 | const bordercolor = styleObj['border-color']
826 | const borderstyle = styleObj['border-style']
827 | if (node.c) {
828 | // padding 和 spacing 默认 2
829 | if (isNaN(padding)) {
830 | padding = 2
831 | }
832 | if (isNaN(spacing)) {
833 | spacing = 2
834 | }
835 | }
836 | if (border) {
837 | attrs.style += `;border:${border}px ${borderstyle || 'solid'} ${bordercolor || 'gray'}`
838 | }
839 | if (node.flag && node.c) {
840 | // 有 colspan 或 rowspan 且含有链接的表格通过 grid 布局实现
841 | styleObj.display = 'grid'
842 | if (spacing) {
843 | styleObj['grid-gap'] = spacing + 'px'
844 | styleObj.padding = spacing + 'px'
845 | } else if (border) {
846 | // 无间隔的情况下避免边框重叠
847 | attrs.style += ';border-left:0;border-top:0'
848 | }
849 |
850 | const width = [] // 表格的列宽
851 | const trList = [] // tr 列表
852 | const cells = [] // 保存新的单元格
853 | const map = {}; // 被合并单元格占用的格子
854 |
855 | (function traversal (nodes) {
856 | for (let i = 0; i < nodes.length; i++) {
857 | if (nodes[i].name === 'tr') {
858 | trList.push(nodes[i])
859 | } else {
860 | traversal(nodes[i].children || [])
861 | }
862 | }
863 | })(children)
864 |
865 | for (let row = 1; row <= trList.length; row++) {
866 | let col = 1
867 | for (let j = 0; j < trList[row - 1].children.length; j++) {
868 | const td = trList[row - 1].children[j]
869 | if (td.name === 'td' || td.name === 'th') {
870 | // 这个格子被上面的单元格占用,则列号++
871 | while (map[row + '.' + col]) {
872 | col++
873 | }
874 | let style = td.attrs.style || ''
875 | let start = style.indexOf('width') ? style.indexOf(';width') : 0
876 | // 提取出 td 的宽度
877 | if (start !== -1) {
878 | let end = style.indexOf(';', start + 6)
879 | if (end === -1) {
880 | end = style.length
881 | }
882 | if (!td.attrs.colspan) {
883 | width[col] = style.substring(start ? start + 7 : 6, end)
884 | }
885 | style = style.substr(0, start) + style.substr(end)
886 | }
887 | // 设置竖直对齐
888 | style += ';display:flex'
889 | start = style.indexOf('vertical-align')
890 | if (start !== -1) {
891 | const val = style.substr(start + 15, 10)
892 | if (val.includes('middle')) {
893 | style += ';align-items:center'
894 | } else if (val.includes('bottom')) {
895 | style += ';align-items:flex-end'
896 | }
897 | } else {
898 | style += ';align-items:center'
899 | }
900 | // 设置水平对齐
901 | start = style.indexOf('text-align')
902 | if (start !== -1) {
903 | const val = style.substr(start + 11, 10)
904 | if (val.includes('center')) {
905 | style += ';justify-content: center'
906 | } else if (val.includes('right')) {
907 | style += ';justify-content: right'
908 | }
909 | }
910 | style = (border ? `;border:${border}px ${borderstyle || 'solid'} ${bordercolor || 'gray'}` + (spacing ? '' : ';border-right:0;border-bottom:0') : '') + (padding ? `;padding:${padding}px` : '') + ';' + style
911 | // 处理列合并
912 | if (td.attrs.colspan) {
913 | style += `;grid-column-start:${col};grid-column-end:${col + parseInt(td.attrs.colspan)}`
914 | if (!td.attrs.rowspan) {
915 | style += `;grid-row-start:${row};grid-row-end:${row + 1}`
916 | }
917 | col += parseInt(td.attrs.colspan) - 1
918 | }
919 | // 处理行合并
920 | if (td.attrs.rowspan) {
921 | style += `;grid-row-start:${row};grid-row-end:${row + parseInt(td.attrs.rowspan)}`
922 | if (!td.attrs.colspan) {
923 | style += `;grid-column-start:${col};grid-column-end:${col + 1}`
924 | }
925 | // 记录下方单元格被占用
926 | for (let rowspan = 1; rowspan < td.attrs.rowspan; rowspan++) {
927 | for (let colspan = 0; colspan < (td.attrs.colspan || 1); colspan++) {
928 | map[(row + rowspan) + '.' + (col - colspan)] = 1
929 | }
930 | }
931 | }
932 | if (style) {
933 | td.attrs.style = style
934 | }
935 | cells.push(td)
936 | col++
937 | }
938 | }
939 | if (row === 1) {
940 | let temp = ''
941 | for (let i = 1; i < col; i++) {
942 | temp += (width[i] ? width[i] : 'auto') + ' '
943 | }
944 | styleObj['grid-template-columns'] = temp
945 | }
946 | }
947 | node.children = cells
948 | } else {
949 | // 没有使用合并单元格的表格通过 table 布局实现
950 | if (node.c) {
951 | styleObj.display = 'table'
952 | }
953 | if (!isNaN(spacing)) {
954 | styleObj['border-spacing'] = spacing + 'px'
955 | }
956 | if (border || padding) {
957 | // 遍历
958 | (function traversal (nodes) {
959 | for (let i = 0; i < nodes.length; i++) {
960 | const td = nodes[i]
961 | if (td.name === 'th' || td.name === 'td') {
962 | if (border) {
963 | td.attrs.style = `border:${border}px ${borderstyle || 'solid'} ${bordercolor || 'gray'};${td.attrs.style || ''}`
964 | }
965 | if (padding) {
966 | td.attrs.style = `padding:${padding}px;${td.attrs.style || ''}`
967 | }
968 | } else if (td.children) {
969 | traversal(td.children)
970 | }
971 | }
972 | })(children)
973 | }
974 | }
975 | // 给表格添加一个单独的横向滚动层
976 | if (this.options.scrollTable && !(attrs.style || '').includes('inline')) {
977 | const table = Object.assign({}, node)
978 | node.name = 'div'
979 | node.attrs = {
980 | style: 'overflow:auto'
981 | }
982 | node.children = [table]
983 | attrs = table.attrs
984 | }
985 | } else if ((node.name === 'td' || node.name === 'th') && (attrs.colspan || attrs.rowspan)) {
986 | for (let i = this.stack.length; i--;) {
987 | if (this.stack[i].name === 'table') {
988 | this.stack[i].flag = 1 // 指示含有合并单元格
989 | break
990 | }
991 | }
992 | } else if (node.name === 'ruby') {
993 | // 转换 ruby
994 | node.name = 'span'
995 | for (let i = 0; i < children.length - 1; i++) {
996 | if (children[i].type === 'text' && children[i + 1].name === 'rt') {
997 | children[i] = {
998 | name: 'div',
999 | attrs: {
1000 | style: 'display:inline-block;text-align:center'
1001 | },
1002 | children: [{
1003 | name: 'div',
1004 | attrs: {
1005 | style: 'font-size:50%;' + (children[i + 1].attrs.style || '')
1006 | },
1007 | children: children[i + 1].children
1008 | }, children[i]]
1009 | }
1010 | children.splice(i + 1, 1)
1011 | }
1012 | }
1013 | } else if (node.c) {
1014 | (function traversal (node) {
1015 | node.c = 2
1016 | for (let i = node.children.length; i--;) {
1017 | const child = node.children[i]
1018 | // #ifdef (MP-WEIXIN || MP-QQ || APP-PLUS || MP-360) && VUE3
1019 | if (child.name && (config.inlineTags[child.name] || ((child.attrs.style || '').includes('inline') && child.children)) && !child.c) {
1020 | traversal(child)
1021 | }
1022 | // #endif
1023 | if (!child.c || child.name === 'table') {
1024 | node.c = 1
1025 | }
1026 | }
1027 | })(node)
1028 | }
1029 |
1030 | if ((styleObj.display || '').includes('flex') && !node.c) {
1031 | for (let i = children.length; i--;) {
1032 | const item = children[i]
1033 | if (item.f) {
1034 | item.attrs.style = (item.attrs.style || '') + item.f
1035 | item.f = undefined
1036 | }
1037 | }
1038 | }
1039 | // flex 布局时部分样式需要提取到 rich-text 外层
1040 | const flex = parent && ((parent.attrs.style || '').includes('flex') || (parent.attrs.style || '').includes('grid'))
1041 | // #ifdef MP-WEIXIN
1042 | // 检查基础库版本 virtualHost 是否可用
1043 | && !(node.c && wx.getNFCAdapter) // eslint-disable-line
1044 | // #endif
1045 | // #ifndef MP-WEIXIN || MP-QQ || MP-BAIDU || MP-TOUTIAO
1046 | && !node.c // eslint-disable-line
1047 | // #endif
1048 | if (flex) {
1049 | node.f = ';max-width:100%'
1050 | }
1051 |
1052 | if (children.length >= 50 && node.c && !(styleObj.display || '').includes('flex')) {
1053 | mergeNodes(children)
1054 | }
1055 | // #endif
1056 |
1057 | for (const key in styleObj) {
1058 | if (styleObj[key]) {
1059 | const val = `;${key}:${styleObj[key].replace(' !important', '')}`
1060 | /* #ifndef APP-PLUS-NVUE */
1061 | if (flex && ((key.includes('flex') && key !== 'flex-direction') || key === 'align-self' || key.includes('grid') || styleObj[key][0] === '-' || (key.includes('width') && val.includes('%')))) {
1062 | node.f += val
1063 | if (key === 'width') {
1064 | attrs.style += ';width:100%'
1065 | }
1066 | } else /* #endif */ {
1067 | attrs.style += val
1068 | }
1069 | }
1070 | }
1071 | attrs.style = attrs.style.substr(1) || undefined
1072 | // #ifdef (MP-WEIXIN || MP-QQ) && VUE3
1073 | for (const key in attrs) {
1074 | if (!attrs[key]) {
1075 | delete attrs[key]
1076 | }
1077 | }
1078 | // #endif
1079 | }
1080 |
1081 | /**
1082 | * @description 解析到文本
1083 | * @param {String} text 文本内容
1084 | */
1085 | Parser.prototype.onText = function (text) {
1086 | if (!this.pre) {
1087 | // 合并空白符
1088 | let trim = ''
1089 | let flag
1090 | for (let i = 0, len = text.length; i < len; i++) {
1091 | if (!blankChar[text[i]]) {
1092 | trim += text[i]
1093 | } else {
1094 | if (trim[trim.length - 1] !== ' ') {
1095 | trim += ' '
1096 | }
1097 | if (text[i] === '\n' && !flag) {
1098 | flag = true
1099 | }
1100 | }
1101 | }
1102 | // 去除含有换行符的空串
1103 | if (trim === ' ') {
1104 | if (flag) return
1105 | // #ifdef VUE3
1106 | else {
1107 | const parent = this.stack[this.stack.length - 1]
1108 | if (parent && parent.name[0] === 't') return
1109 | }
1110 | // #endif
1111 | }
1112 | text = trim
1113 | }
1114 | const node = Object.create(null)
1115 | node.type = 'text'
1116 | // #ifdef (MP-BAIDU || MP-ALIPAY || MP-TOUTIAO) && VUE3
1117 | node.attrs = {}
1118 | // #endif
1119 | node.text = decodeEntity(text)
1120 | if (this.hook(node)) {
1121 | // #ifdef MP-WEIXIN
1122 | if (this.options.selectable === 'force' && system.includes('iOS') && !uni.canIUse('rich-text.user-select')) {
1123 | this.expose()
1124 | }
1125 | // #endif
1126 | const siblings = this.stack.length ? this.stack[this.stack.length - 1].children : this.nodes
1127 | siblings.push(node)
1128 | }
1129 | }
1130 |
1131 | /**
1132 | * @description html 词法分析器
1133 | * @param {Object} handler 高层处理器
1134 | */
1135 | function Lexer (handler) {
1136 | this.handler = handler
1137 | }
1138 |
1139 | /**
1140 | * @description 执行解析
1141 | * @param {String} content 要解析的文本
1142 | */
1143 | Lexer.prototype.parse = function (content) {
1144 | this.content = content || ''
1145 | this.i = 0 // 标记解析位置
1146 | this.start = 0 // 标记一个单词的开始位置
1147 | this.state = this.text // 当前状态
1148 | for (let len = this.content.length; this.i !== -1 && this.i < len;) {
1149 | this.state()
1150 | }
1151 | }
1152 |
1153 | /**
1154 | * @description 检查标签是否闭合
1155 | * @param {String} method 如果闭合要进行的操作
1156 | * @returns {Boolean} 是否闭合
1157 | * @private
1158 | */
1159 | Lexer.prototype.checkClose = function (method) {
1160 | const selfClose = this.content[this.i] === '/'
1161 | if (this.content[this.i] === '>' || (selfClose && this.content[this.i + 1] === '>')) {
1162 | if (method) {
1163 | this.handler[method](this.content.substring(this.start, this.i))
1164 | }
1165 | this.i += selfClose ? 2 : 1
1166 | this.start = this.i
1167 | this.handler.onOpenTag(selfClose)
1168 | if (this.handler.tagName === 'script') {
1169 | this.i = this.content.indexOf('', this.i)
1170 | if (this.i !== -1) {
1171 | this.i += 2
1172 | this.start = this.i
1173 | }
1174 | this.state = this.endTag
1175 | } else {
1176 | this.state = this.text
1177 | }
1178 | return true
1179 | }
1180 | return false
1181 | }
1182 |
1183 | /**
1184 | * @description 文本状态
1185 | * @private
1186 | */
1187 | Lexer.prototype.text = function () {
1188 | this.i = this.content.indexOf('<', this.i) // 查找最近的标签
1189 | if (this.i === -1) {
1190 | // 没有标签了
1191 | if (this.start < this.content.length) {
1192 | this.handler.onText(this.content.substring(this.start, this.content.length))
1193 | }
1194 | return
1195 | }
1196 | const c = this.content[this.i + 1]
1197 | if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')) {
1198 | // 标签开头
1199 | if (this.start !== this.i) {
1200 | this.handler.onText(this.content.substring(this.start, this.i))
1201 | }
1202 | this.start = ++this.i
1203 | this.state = this.tagName
1204 | } else if (c === '/' || c === '!' || c === '?') {
1205 | if (this.start !== this.i) {
1206 | this.handler.onText(this.content.substring(this.start, this.i))
1207 | }
1208 | const next = this.content[this.i + 2]
1209 | if (c === '/' && ((next >= 'a' && next <= 'z') || (next >= 'A' && next <= 'Z'))) {
1210 | // 标签结尾
1211 | this.i += 2
1212 | this.start = this.i
1213 | this.state = this.endTag
1214 | return
1215 | }
1216 | // 处理注释
1217 | let end = '-->'
1218 | if (c !== '!' || this.content[this.i + 2] !== '-' || this.content[this.i + 3] !== '-') {
1219 | end = '>'
1220 | }
1221 | this.i = this.content.indexOf(end, this.i)
1222 | if (this.i !== -1) {
1223 | this.i += end.length
1224 | this.start = this.i
1225 | }
1226 | } else {
1227 | this.i++
1228 | }
1229 | }
1230 |
1231 | /**
1232 | * @description 标签名状态
1233 | * @private
1234 | */
1235 | Lexer.prototype.tagName = function () {
1236 | if (blankChar[this.content[this.i]]) {
1237 | // 解析到标签名
1238 | this.handler.onTagName(this.content.substring(this.start, this.i))
1239 | while (blankChar[this.content[++this.i]]);
1240 | if (this.i < this.content.length && !this.checkClose()) {
1241 | this.start = this.i
1242 | this.state = this.attrName
1243 | }
1244 | } else if (!this.checkClose('onTagName')) {
1245 | this.i++
1246 | }
1247 | }
1248 |
1249 | /**
1250 | * @description 属性名状态
1251 | * @private
1252 | */
1253 | Lexer.prototype.attrName = function () {
1254 | let c = this.content[this.i]
1255 | if (blankChar[c] || c === '=') {
1256 | // 解析到属性名
1257 | this.handler.onAttrName(this.content.substring(this.start, this.i))
1258 | let needVal = c === '='
1259 | const len = this.content.length
1260 | while (++this.i < len) {
1261 | c = this.content[this.i]
1262 | if (!blankChar[c]) {
1263 | if (this.checkClose()) return
1264 | if (needVal) {
1265 | // 等号后遇到第一个非空字符
1266 | this.start = this.i
1267 | this.state = this.attrVal
1268 | return
1269 | }
1270 | if (this.content[this.i] === '=') {
1271 | needVal = true
1272 | } else {
1273 | this.start = this.i
1274 | this.state = this.attrName
1275 | return
1276 | }
1277 | }
1278 | }
1279 | } else if (!this.checkClose('onAttrName')) {
1280 | this.i++
1281 | }
1282 | }
1283 |
1284 | /**
1285 | * @description 属性值状态
1286 | * @private
1287 | */
1288 | Lexer.prototype.attrVal = function () {
1289 | const c = this.content[this.i]
1290 | const len = this.content.length
1291 | if (c === '"' || c === "'") {
1292 | // 有冒号的属性
1293 | this.start = ++this.i
1294 | this.i = this.content.indexOf(c, this.i)
1295 | if (this.i === -1) return
1296 | this.handler.onAttrVal(this.content.substring(this.start, this.i))
1297 | } else {
1298 | // 没有冒号的属性
1299 | for (; this.i < len; this.i++) {
1300 | if (blankChar[this.content[this.i]]) {
1301 | this.handler.onAttrVal(this.content.substring(this.start, this.i))
1302 | break
1303 | } else if (this.checkClose('onAttrVal')) return
1304 | }
1305 | }
1306 | while (blankChar[this.content[++this.i]]);
1307 | if (this.i < len && !this.checkClose()) {
1308 | this.start = this.i
1309 | this.state = this.attrName
1310 | }
1311 | }
1312 |
1313 | /**
1314 | * @description 结束标签状态
1315 | * @returns {String} 结束的标签名
1316 | * @private
1317 | */
1318 | Lexer.prototype.endTag = function () {
1319 | const c = this.content[this.i]
1320 | if (blankChar[c] || c === '>' || c === '/') {
1321 | this.handler.onCloseTag(this.content.substring(this.start, this.i))
1322 | if (c !== '>') {
1323 | this.i = this.content.indexOf('>', this.i)
1324 | if (this.i === -1) return
1325 | }
1326 | this.start = ++this.i
1327 | this.state = this.text
1328 | } else {
1329 | this.i++
1330 | }
1331 | }
1332 |
1333 | export default Parser
1334 |
--------------------------------------------------------------------------------