ncmatch头部问题

This commit is contained in:
hrx 2026-07-15 16:37:57 +08:00
parent 73aae255da
commit d97802f928
11 changed files with 2477 additions and 232 deletions

View File

@ -22,6 +22,8 @@
"dependencies": {
"@form-create/element-ui": "^2.5.30",
"@jiaminghi/data-view": "^2.10.0",
"@wangeditor/editor": "^5.1.23",
"@wangeditor/editor-for-vue": "^1.0.2",
"@xterm/xterm": "^5.5.0",
"amfe-flexible": "^2.2.1",
"axios": "0.18.1",

View File

@ -0,0 +1,74 @@
import request from "@/utils/request";
// 前台新闻列表
export const reqNewsList = (data) => {
return request({
url: '/news/front_news_search.dspy',
method: 'post',
data
})
}
// 后台新闻列表
export const reqNewsListAdmin = (data) => {
return request({
url: '/news/news_article_search.dspy',
method: 'get',
params: data
})
}
// 新闻详情
export const reqNewsDetail = (data) => {
// 调用文章详情接口,用于编辑时获取完整回显数据。
return request({
// 详情接口地址。
url: '/news/front_news_detail.dspy',
// 详情接口使用 GET 查询。
method: 'get',
// 传入文章 id 和当前页面 url_link 等查询参数。
params: data
})
}
// 添加新闻
export const reqAddNews = (data) => {
const isFormData = data instanceof FormData
return request({
url: '/news/news_article_add.dspy',
method: isFormData ? 'post' : 'get',
params: isFormData ? undefined : data,
data: isFormData ? data : undefined,
headers: isFormData ? { 'Content-Type': 'multipart/form-data' } : undefined
})
}
// 编辑新闻
export const reqEditNews = (data) => {
const isFormData = data instanceof FormData
return request({
url: '/news/news_article_update.dspy',
method: 'post',
data,
headers: isFormData ? { 'Content-Type': 'multipart/form-data' } : undefined
})
}
// 删除新闻
export const reqDeleteNews = (data) => {
return request({
url: '/news/news_article_delete.dspy',
method: 'get',
params: data
})
}
// 上架新闻
export const reqPublishNews = (data) => {
return request({
url: '/news/news_article_publish.dspy',
method: 'get',
params: data
})
}
// 下架新闻
export const reqUnpublishNews = (data) => {
return request({
url: '/news/news_article_offline.dspy',
method: 'get',
params: data
})
}

View File

@ -118,6 +118,7 @@ export default {
.happy-scroll-content {
width: 100%;
min-width: unset !important;
padding-bottom: 78px;
box-sizing: border-box;
}
}
@ -159,6 +160,7 @@ export default {
height: 100%;
width: 100% !important;
min-width: 100% !important;
padding-bottom: 78px;
box-sizing: border-box;
.el-submenu__title,

View File

@ -598,6 +598,32 @@ export const asyncRoutes = [
]
},
// 运营——新闻动态
{
path: "/newsLog",
component: Layout,
meta: {
title: "新闻动态",
fullPath: "/newsLog",
noCache: true,
icon: "el-icon-document",
roles: ["运营"]
},
children: [
{
path: "",
component: () => import('@/views/homePage/news/newsLog.vue'),
name: 'NewsLog',
meta: {
title: "新闻动态",
fullPath: "/newsLog",
noCache: true,
roles: ["运营"]
}
},
]
},
// token市集 - 一级菜单(所有登录用户都能看到)
{

View File

@ -18,7 +18,7 @@ const SUPER_ADMIN_ROUTE_PATH = '/superAdministrator';
const COMMON_ROUTE_PATHS = ['/product', '/tokenManagement', '/tokenUsage', '/modelExperience', '/modelDetail', '/modelApiDocument'];
// 运营角色需要额外补出来的菜单。
const OPERATION_EXTRA_ROUTE_PATHS = ['/modelManagement', '/modelInfoConfig', '/operationReport'];
const OPERATION_EXTRA_ROUTE_PATHS = ['/modelManagement', '/modelInfoConfig', '/operationReport', '/newsLog'];
// 财务角色需要额外补出来的菜单。
const FINANCE_EXTRA_ROUTE_PATHS = ['/financialOverview'];

View File

@ -0,0 +1,810 @@
<template>
<div>
<el-dialog
:visible.sync="dialogVisible"
width="540px"
append-to-body
custom-class="glass-dialog"
:close-on-click-modal="true"
:close-on-press-escape="true"
@close="handleClose"
>
<div class="login-card">
<div class="brand-area">
<div class="brand-text">
<h2>{{ activeTab === 'login' ? '欢迎回来' : '创建账号' }}</h2>
<p>{{ activeTab === 'login' ? '登录后继续使用智能服务' : '注册开启全新体验' }}</p>
</div>
</div>
<div class="tab-group">
<button type="button" :class="{ active: activeTab === 'login' }" @click="switchTab('login')">登录</button>
<button type="button" :class="{ active: activeTab === 'register' }" @click="switchTab('register')">注册</button>
</div>
<div v-if="activeTab === 'login'" class="form-panel">
<div class="mode-tabs">
<button type="button" :class="{ active: loginMode === 'password' }" @click="switchLoginMode('password')">密码登录</button>
<button type="button" :class="{ active: loginMode === 'mobile' }" @click="switchLoginMode('mobile')">验证码登录</button>
</div>
<el-form ref="loginForm" :model="loginForm" :rules="activeLoginRules" label-position="top">
<template v-if="loginMode === 'password'">
<el-form-item prop="username">
<el-input v-model.trim="loginForm.username" placeholder="请输入账户" prefix-icon="el-icon-user" @keyup.enter.native="handleLogin" />
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="loginForm.password"
:type="loginPwdVisible ? 'text' : 'password'"
placeholder="请输入密码"
prefix-icon="el-icon-lock"
@keyup.enter.native="handleLogin"
>
<i slot="suffix" class="el-icon-view password-eye" @click="loginPwdVisible = !loginPwdVisible"></i>
</el-input>
</el-form-item>
</template>
<template v-else>
<el-form-item prop="mobile">
<el-input v-model.trim="loginForm.mobile" placeholder="请输入手机号" prefix-icon="el-icon-mobile-phone" />
</el-form-item>
<el-form-item prop="vcode">
<div class="code-row">
<el-input v-model.trim="loginForm.vcode" placeholder="请输入验证码" prefix-icon="el-icon-key" @keyup.enter.native="handleLogin" />
<button type="button" class="code-btn" :disabled="loginCodeDisabled" @click="getLoginCode">{{ loginCodeText }}</button>
</div>
</el-form-item>
</template>
</el-form>
<div class="form-actions">
<button type="button" class="text-btn" @click="forgotPasswordVisible = true">忘记密码</button>
<button type="button" class="text-btn" @click="switchTab('register')">没有账号去注册</button>
</div>
<button type="button" class="main-btn" :disabled="loginLoading" @click="handleLogin">
{{ loginLoading ? '登录中...' : '立即登录' }}
</button>
</div>
<div v-else class="form-panel">
<el-form ref="registerForm" :model="registerForm" :rules="registerRules" label-position="top">
<el-form-item prop="mobile">
<el-input v-model.trim="registerForm.mobile" class="phone-input" placeholder="请输入手机号">
<span slot="prefix" class="country-prefix">+86</span>
</el-input>
</el-form-item>
<el-form-item prop="vcode">
<div class="code-row">
<el-input v-model.trim="registerForm.vcode" placeholder="请输入验证码" prefix-icon="el-icon-key" />
<button type="button" class="code-btn" :disabled="regCodeDisabled" @click="getRegisterCode">{{ regCodeText }}</button>
</div>
</el-form-item>
<el-form-item prop="username">
<el-input v-model.trim="registerForm.username" placeholder="请输入账户名" prefix-icon="el-icon-user" />
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="registerForm.password"
:type="regPwdVisible ? 'text' : 'password'"
placeholder="请输入密码"
prefix-icon="el-icon-lock"
@keyup.enter.native="handleRegister"
>
<i slot="suffix" class="el-icon-view password-eye" @click="regPwdVisible = !regPwdVisible"></i>
</el-input>
</el-form-item>
</el-form>
<div class="agreement-wrap">
<el-checkbox v-model="registerForm.agree">
我已阅读并同意用户协议隐私政策产品服务协议
</el-checkbox>
</div>
<button type="button" class="main-btn" :disabled="registerLoading" @click="handleRegister">
{{ registerLoading ? '注册中...' : '立即注册' }}
</button>
<button type="button" class="bottom-link" @click="switchTab('login')">已有账号前往登录</button>
</div>
</div>
</el-dialog>
<forgot-password-dialog :visible.sync="forgotPasswordVisible" />
</div>
</template>
<script>
import { JSEncrypt } from 'jsencrypt'
import router, { resetRouter } from '@/router'
import { getCodeAPI, logintypeAPI } from '@/api/login'
import { register, sendCode } from '@/api/login'
import { getHomePath } from '@/views/setting/tools'
import ForgotPasswordDialog from '@/views/login/components/ForgotPasswordDialog.vue'
export default {
name: 'LoginDialog',
components: { ForgotPasswordDialog },
props: {
visible: {
type: Boolean,
default: false
}
},
data() {
const validatePhone = (rule, value, callback) => {
if (!value) callback(new Error('请输入手机号'))
else if (!/^1[3-9]\d{9}$/.test(value)) callback(new Error('请输入正确的手机号'))
else callback()
}
return {
activeTab: 'login',
loginMode: 'password',
forgotPasswordVisible: false,
loginLoading: false,
registerLoading: false,
loginPwdVisible: false,
regPwdVisible: false,
loginCodeDisabled: false,
regCodeDisabled: false,
loginCodeText: '获取验证码',
regCodeText: '获取验证码',
loginTimer: null,
regTimer: null,
loginCount: 60,
regCount: 60,
loginForm: {
username: '',
password: '',
mobile: '',
vcode: '',
codeid: ''
},
registerForm: {
mobile: '',
vcode: '',
codeid: '',
username: '',
password: '',
org_type: '2',
agree: false,
wechat_openid: localStorage.getItem('wechat_openid') || '',
domain_name: window.location.hostname
},
loginRules: {
username: [{ required: true, message: '请输入账户', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
mobile: [{ required: true, validator: validatePhone, trigger: 'blur' }],
vcode: [{ required: true, message: '请输入验证码', trigger: 'blur' }]
},
registerRules: {
mobile: [{ required: true, validator: validatePhone, trigger: 'blur' }],
vcode: [{ required: true, message: '请输入验证码', trigger: 'blur' }],
username: [{ required: true, message: '请输入账户名', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
}
}
},
computed: {
dialogVisible: {
get() {
return this.visible
},
set(value) {
this.$emit('update:visible', value)
}
},
activeLoginRules() {
if (this.loginMode === 'mobile') {
return {
mobile: this.loginRules.mobile,
vcode: this.loginRules.vcode
}
}
return {
username: this.loginRules.username,
password: this.loginRules.password
}
}
},
beforeDestroy() {
clearInterval(this.loginTimer)
clearInterval(this.regTimer)
},
methods: {
handleClose() {
this.loginLoading = false
this.registerLoading = false
this.$emit('close')
},
switchTab(tab) {
this.activeTab = tab
this.$nextTick(() => {
if (this.$refs.loginForm) this.$refs.loginForm.clearValidate()
if (this.$refs.registerForm) this.$refs.registerForm.clearValidate()
})
},
switchLoginMode(mode) {
this.loginMode = mode
this.$nextTick(() => {
if (this.$refs.loginForm) this.$refs.loginForm.clearValidate()
})
},
passwordEncryption(passwordUser) {
const publicKey = '-----BEGIN PUBLIC KEY-----\n' +
'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApJ3ThUWT3CgvH0O8rrT6\n' +
'qycpqX0NTq4Q3CxBrvNxo9//qX2bKvhomoLNd+vdti8xNOK6/3zuTJIVt0RoNKwE\n' +
'0HWMR8H0jgp7ING54DtT5B8bhmUpbs/hownGzIBGOedhqeOPiv0Q5oSi9OIEE+PK\n' +
'2L8KdgFF2Z6Q1DQdv5Y1qvD/t2mJVjR+NPTwcwBIT8UJ0Cfu8lqHjjJbNF//smTj\n' +
'Q8v2pnqp19jItuHeD4G4u7a8fWC3/IGEv4+uc5rq5qhwdzRxHUveNmoE+nyh0T8R\n' +
'C8Y8/XLkEiD0nMvZZjBn7Bof6f1st0aqJX8R1VGvzdTJ8eTvJuyMNsR4wLoF5Pvx\n' +
'hQIDAQAB\n' +
'-----END PUBLIC KEY-----'
const encryptor = new JSEncrypt()
encryptor.setPublicKey(publicKey)
return encryptor.encrypt(passwordUser)
},
buildLoginParams() {
const commonParams = {
domain_name: window.location.hostname,
wechat_openid: ''
}
if (this.loginMode === 'mobile') {
return {
...commonParams,
mobile: this.loginForm.mobile,
vcode: this.loginForm.vcode,
codeid: this.loginForm.codeid
}
}
return {
...commonParams,
username: this.loginForm.username,
password: this.passwordEncryption(this.loginForm.password)
}
},
startCountdown(type) {
const isLogin = type === 'login'
const timerKey = isLogin ? 'loginTimer' : 'regTimer'
const countKey = isLogin ? 'loginCount' : 'regCount'
const textKey = isLogin ? 'loginCodeText' : 'regCodeText'
const disabledKey = isLogin ? 'loginCodeDisabled' : 'regCodeDisabled'
clearInterval(this[timerKey])
this[countKey] = 59
this[disabledKey] = true
this[textKey] = `重新发送 ${this[countKey]}s`
this[timerKey] = setInterval(() => {
if (this[countKey] > 0) {
this[countKey] -= 1
this[textKey] = `重新发送 ${this[countKey]}s`
return
}
clearInterval(this[timerKey])
this[timerKey] = null
this[countKey] = 60
this[disabledKey] = false
this[textKey] = '获取验证码'
}, 1000)
},
getLoginCode() {
this.$refs.loginForm.validateField('mobile', async error => {
if (error) return
try {
const res = await getCodeAPI({ mobile: this.loginForm.mobile, action_type: 'login' })
if (res.status) {
this.loginForm.codeid = res.codeid || (res.data && res.data.codeid) || ''
this.startCountdown('login')
this.$message.success('验证码已发送')
} else {
this.$message.error(res.msg || '验证码获取失败')
}
} catch (error) {
this.$message.error('验证码获取失败')
}
})
},
getRegisterCode() {
this.$refs.registerForm.validateField('mobile', async error => {
if (error) return
try {
const res = await sendCode({ mobile: this.registerForm.mobile, action_type: 'register' })
if (res.status) {
this.registerForm.codeid = res.codeid || (res.data && res.data.codeid) || res.data || ''
this.startCountdown('register')
this.$message.success('验证码已发送')
} else {
this.$message.error(res.msg || '验证码获取失败')
}
} catch (error) {
this.$message.error('验证码获取失败')
}
})
},
handleLogin() {
this.$refs.loginForm.validate(async valid => {
if (!valid) return
const loginParams = this.buildLoginParams()
this.loginLoading = true
try {
const check = await logintypeAPI(loginParams)
if (!check.status) {
this.$message.error(check.msg || '登录失败')
return
}
const res = await this.$store.dispatch('user/login', loginParams)
if (!res.status) {
this.$message.error(res.msg || '登录失败')
return
}
localStorage.setItem('user_info', JSON.stringify(res.user))
sessionStorage.setItem('userId', res.userId)
sessionStorage.setItem('orgid', res.user.orgid)
sessionStorage.setItem('org_type', res.org_type)
sessionStorage.setItem('username', res.user.username)
if (res.admin !== 1) {
sessionStorage.setItem('juese', res.roles[0])
sessionStorage.setItem('jueseNew', res.roles)
} else {
sessionStorage.setItem('juese', 'admin')
sessionStorage.setItem('jueseNew', 'admin')
}
sessionStorage.setItem('roles', JSON.stringify(res.admin !== 1 ? res.roles : ['admin']))
this.$store.commit('setLoginState', true)
resetRouter()
this.$store.commit('permission/RESET_ROUTES')
const routeRoles = res.admin !== 1 ? res.roles : ['admin']
const accessRoutes = await this.$store.dispatch('permission/generateRoutes', {
user: res.user.username,
auths: res.data,
admin: res.admin || '',
orgType: res.org_type,
roles: routeRoles
})
router.addRoutes(accessRoutes)
this.$message.success(res.msg || '登录成功')
this.dialogVisible = false
this.$emit('success', res)
this.redirectAfterLogin(res)
} catch (error) {
this.$message.error((error && error.msg) || '登录失败')
} finally {
this.loginLoading = false
}
})
},
redirectAfterLogin(res) {
const redirectPath = sessionStorage.getItem('loginRedirectPath')
if (redirectPath && redirectPath.startsWith('/') && !redirectPath.includes('/login')) {
sessionStorage.removeItem('loginRedirectPath')
this.$router.push(redirectPath).catch(() => {})
return
}
if (res.admin === 1) this.$router.push('/').catch(() => {})
else if (String(res.org_type) === '2' || String(res.org_type) === '3') this.$router.push(getHomePath()).catch(() => {})
else if (res.roles && res.roles.includes('运营')) this.$router.push('/operation/supplierManagement').catch(() => {})
else if (res.roles && res.roles.includes('销售')) this.$router.push('/sales/distributorManagement').catch(() => {})
else if (res.roles && res.roles.includes('运维')) this.$router.push('/operationAndMaintenance/workOrderProcessing').catch(() => {})
else this.$router.push('/').catch(() => {})
},
handleRegister() {
if (!this.registerForm.agree) {
this.$message.warning('请先阅读并同意相关协议')
return
}
this.$refs.registerForm.validate(async valid => {
if (!valid) return
this.registerLoading = true
try {
const registerData = {
mobile: this.registerForm.mobile,
vcode: this.registerForm.vcode,
codeid: this.registerForm.codeid,
org_type: '2',
username: this.registerForm.username || this.registerForm.mobile,
password: this.registerForm.password,
nick_name: this.registerForm.mobile,
wechat_openid: this.registerForm.wechat_openid,
domain_name: this.registerForm.domain_name || window.location.hostname
}
const res = await register(registerData)
if (res.status) {
this.$message.success('注册成功,请登录')
this.loginForm.mobile = this.registerForm.mobile
this.switchTab('login')
this.loginMode = 'mobile'
this.$refs.registerForm.resetFields()
this.registerForm.agree = false
} else {
this.$message.error(res.message || res.msg || '注册失败')
}
} catch (error) {
this.$message.error('注册失败,请重试')
} finally {
this.registerLoading = false
}
})
}
}
}
</script>
<style scoped lang="less">
::v-deep .glass-dialog {
overflow: hidden;
// border: 1px solid rgba(255, 255, 255, 0.9);
border-radius: 28px;
background: #fff;
box-shadow:
0 30px 80px rgba(15, 23, 42, 0.16),
inset 0 1px 0 rgba(255, 255, 255, 0.95);
backdrop-filter: blur(28px) saturate(1.35);
-webkit-backdrop-filter: blur(28px) saturate(1.35);
.el-dialog__header {
display: none;
}
.el-dialog__body {
padding: 0;
}
.el-dialog__headerbtn {
position: absolute;
top: 28px;
right: 28px;
z-index: 10;
width: 42px;
height: 42px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid rgba(255, 255, 255, 0.78);
border-radius: 50%;
background: rgba(255, 255, 255, 0.62);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
transition: all 0.2s ease;
&:hover {
background: rgba(255, 255, 255, 0.86);
}
.el-dialog__close {
color: #1f2937;
font-size: 20px;
}
}
}
.login-card {
position: relative;
padding: 40px 44px 36px;
}
.brand-area {
display: flex;
gap: 18px;
align-items: center;
margin-bottom: 30px;
}
.brand-badge {
width: 60px;
height: 60px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 24px;
font-weight: 800;
letter-spacing: -0.5px;
border-radius: 20px;
background: linear-gradient(135deg, #2563eb, #7c3aed);
box-shadow: 0 14px 30px rgba(37, 99, 235, 0.3);
}
.brand-text h2 {
margin: 0;
color: #020817;
font-size: 30px;
line-height: 1.2;
font-weight: 800;
letter-spacing: -0.04em;
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.55);
}
.brand-text p {
margin: 4px 0 0;
color: #263548;
font-size: 14px;
font-weight: 500;
}
.tab-group {
display: flex;
height: 52px;
padding: 0;
margin-bottom: 34px;
background: transparent;
border-bottom: 1px solid rgba(15, 23, 42, 0.26);
border-radius: 0;
box-shadow: none;
}
.tab-group button {
flex: 1;
padding: 0;
border: none;
border-bottom: 3px solid transparent;
border-radius: 0;
background: transparent;
color: #1f2937;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.25s ease;
}
.tab-group button.active {
color: #020617;
background: transparent;
border-bottom-color: #020617;
box-shadow: none;
}
.mode-tabs {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-bottom: 22px;
}
.mode-tabs button {
height: 46px;
padding: 0;
border: none;
border: 1px solid rgba(15, 23, 42, 0.72);
border-radius: 14px;
background: rgba(255, 255, 255, 0.66);
color: #1f2937;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.mode-tabs button.active {
color: #020617;
background: rgba(255, 255, 255, 0.9);
border-color: #020617;
}
.form-panel ::v-deep .el-form-item {
margin-bottom: 16px;
}
.form-panel ::v-deep .el-form-item__error {
color: #ef4444;
font-weight: 500;
}
.form-panel ::v-deep .el-input__inner {
height: 48px;
padding-left: 44px;
border: 1px solid #c8d3e2;
border-radius: 14px;
background: rgba(255, 255, 255, 0.94);
color: #020817;
font-size: 15px;
font-weight: 500;
transition: all 0.2s ease;
}
.form-panel ::v-deep .el-input__inner:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.12);
background: #ffffff;
}
.form-panel ::v-deep .el-input__inner::placeholder {
color: #64748b;
font-weight: 400;
}
.form-panel ::v-deep .el-input__prefix {
left: 16px;
display: flex;
align-items: center;
color: #475569;
font-size: 18px;
}
.form-panel ::v-deep .el-input__suffix {
right: 16px;
display: flex;
align-items: center;
}
.password-eye {
color: #475569;
cursor: pointer;
font-size: 18px;
transition: color 0.2s;
&:hover {
color: #475569;
}
}
.code-row {
display: flex;
gap: 10px;
}
.code-row .el-input {
flex: 1;
}
.code-btn {
height: 48px;
padding: 0 18px;
color: #1d4ed8;
font-size: 14px;
font-weight: 600;
white-space: nowrap;
cursor: pointer;
border: 1px solid rgba(37, 99, 235, 0.35);
border-radius: 14px;
background: rgba(255, 255, 255, 0.94);
transition: all 0.2s ease;
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.form-actions {
display: flex;
justify-content: space-between;
margin: 2px 0 20px;
}
.text-btn,
.bottom-link {
padding: 4px 0;
border: none;
background: transparent;
color: #1f2937;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: color 0.2s;
&:hover {
color: #2563eb;
}
}
.main-btn {
width: 100%;
height: 50px;
border: none;
border-radius: 999px;
// background: linear-gradient(90deg, #2463f6, #24b9f1);
background-color: #020617;
color: #fff;
font-size: 17px;
font-weight: 700;
letter-spacing: 0.3px;
cursor: pointer;
box-shadow: 0 14px 32px rgba(37, 99, 235, 0.32);
transition: all 0.25s ease;
}
.main-btn:hover:not(:disabled) {
box-shadow: 0 18px 40px rgba(37, 99, 235, 0.34);
transform: translateY(-1px);
}
.main-btn:disabled {
opacity: 0.72;
cursor: not-allowed;
}
.country-prefix {
display: inline-flex;
align-items: center;
height: 100%;
padding-left: 12px;
color: #1f2937;
font-size: 13px;
font-weight: 500;
}
.form-panel ::v-deep .phone-input .el-input__prefix {
left: 0;
width: 54px;
justify-content: center;
pointer-events: none;
}
.form-panel ::v-deep .phone-input .el-input__inner {
padding-left: 58px;
}
.agreement-wrap {
margin: 4px 0 20px;
::v-deep .el-checkbox__label {
color: #1f2937;
font-size: 12px;
line-height: 1.6;
white-space: normal;
}
}
.bottom-link {
display: block;
width: 100%;
margin-top: 16px;
text-align: center;
}
@media (max-width: 640px) {
::v-deep .glass-dialog {
width: calc(100vw - 28px) !important;
border-radius: 22px;
}
.login-card {
padding: 32px 22px 28px;
}
.brand-badge {
width: 52px;
height: 52px;
font-size: 20px;
border-radius: 16px;
}
.brand-text h2 {
font-size: 26px;
}
.brand-text p {
font-size: 13px;
}
.tab-group {
height: 52px;
margin-bottom: 24px;
}
.tab-group button {
font-size: 17px;
}
.mode-tabs {
gap: 18px;
}
.mode-tabs button {
height: 36px;
font-size: 14px;
}
}
</style>

View File

@ -51,16 +51,17 @@
</div>
</div>
<button type="button" class="nav-item" @click.stop="goHomeAnchor('cases-section')">
<button v-if="!isNcmatchDomain" type="button" class="nav-item" @click.stop="goHomeAnchor('cases-section')">
{{ $t('topbar.cases') }}
</button>
<button type="button" class="nav-item" @click.stop="goHomeAnchor('news')">
<button v-if="!isNcmatchDomain" type="button" class="nav-item" @click.stop="goHomeAnchor('news')">
{{ $t('topbar.news') }}
</button>
</nav>
<div class="user-actions">
<button
v-if="!isNcmatchDomain"
type="button"
class="nav-lang-text"
:class="{ 'is-en': activeLocale === 'en-US' }"
@ -76,7 +77,7 @@
{{ $t('topbar.console') }}
</button>
<button v-if="!loginState" type="button" class="login-btn" @click.stop="goLogin">
<button v-if="!loginState" type="button" class="login-btn" @click.stop="openLoginDialog">
{{ $t('topbar.login') }}
</button>
@ -224,6 +225,12 @@
:user-id="userId"
@unread-count-update="handleUnreadCountUpdate"
/>
<login-dialog
:visible.sync="loginDialogVisible"
@success="handleLoginDialogSuccess"
@forgot-password="goLogin"
/>
</div>
</template>
@ -237,13 +244,14 @@ import { reqAIChat } from '@/api/AI/ai'
import { gotoYuanJingAPI } from '@/api/gotoYuanJing'
import { setLocale } from '@/i18n'
import MessageCenter from '@/components/MessageCenter/MessageCenter.vue'
import LoginDialog from '@/views/LoginDialog/LoginDialog.vue'
const LOCALE_KEY = 'kboss-locale'
const SUPPORTED_LOCALES = ['zh-CN', 'en-US']
export default Vue.extend({
name: 'TopBox',
components: { MessageCenter },
components: { MessageCenter, LoginDialog },
data() {
return {
// AI
@ -266,6 +274,7 @@ export default Vue.extend({
messageCount: 0,
activeLocale: 'zh-CN',
isProductPanelVisible: false,
loginDialogVisible: false,
//
isShowKbossCharge: false,
@ -494,6 +503,18 @@ export default Vue.extend({
// =========================
//
// =========================
openLoginDialog() {
this.closeProductPanelImmediate()
if (this.$route && this.$route.fullPath && !this.$route.fullPath.includes('/login')) {
sessionStorage.setItem('loginRedirectPath', this.$route.fullPath)
}
this.loginDialogVisible = true
},
handleLoginDialogSuccess() {
this.loginDialogVisible = false
this.nick_name = sessionStorage.getItem('username') || ''
this.userId = sessionStorage.getItem('userId')
},
goLogin() {
this.closeProductPanelImmediate()
if (this.$route && this.$route.fullPath && !this.$route.fullPath.includes('/login')) {
@ -528,12 +549,6 @@ export default Vue.extend({
else if (role.includes('admin')) this.$router.push('/superAdministrator/addAdmin')
},
async logout() {
let redirectPath = ''
if (this.$route && this.$route.fullPath && !this.$route.fullPath.includes('/login')) {
redirectPath = this.$route.fullPath
sessionStorage.setItem('loginRedirectPath', redirectPath)
}
this.$store.commit('setLoginState', false)
store.commit('tagsView/resetBreadcrumbState')
store.commit('permission/RESET_ROUTES')
@ -548,17 +563,17 @@ export default Vue.extend({
sessionStorage.removeItem('roles')
sessionStorage.removeItem('juese')
sessionStorage.removeItem('jueseNew')
sessionStorage.removeItem('loginRedirectPath')
localStorage.removeItem('auths')
localStorage.removeItem('routes')
localStorage.removeItem('user')
localStorage.removeItem('userId')
localStorage.removeItem('org_type')
this.userId = ''
this.nick_name = ''
await this.$router.push({
path: '/login',
query: redirectPath ? { redirect: redirectPath } : {}
})
await this.$router.replace(getHomePath()).catch(() => {})
},
initMybalance() {
return sessionStorage.getItem('mybalance')
@ -928,7 +943,7 @@ export default Vue.extend({
.login-btn {
height: 48px;
width: 100px;
width: 86px;
padding: 0;
border: 0;
border-radius: 99px;

View File

@ -155,6 +155,7 @@
</template>
<script>
import { reqNewsList } from '@/api/newsapi/newsapi'
import Talk from '@/views/homePage/dialog/talk/index.vue'
export default {
@ -193,138 +194,123 @@ export default {
image: require('./img/case/beigang-big-data-case.jpg')
}
],
newsList: [
{
tag: '企业动态',
tagClass: 'news-tag--blue',
date: '2026.6.15',
title: '开元云随贸促会走访东盟',
desc: '开元云科技随贸促会广西分会经贸代表团密集出访越南、老挝,深度参与区域 AI 产业合作与数字经济交流。'
},
{
tag: '企业动态',
tagClass: 'news-tag--green',
date: '2026.05.17',
title: '开元云荣登福布斯中国人工智能商业落地示范企业',
desc: '凭借 AI 智能体工厂的工业级交付能力与央国企标杆案例,开元云入选福布斯中国人工智能商业落地示范企业。'
}
],
newsList: [],
solutionCards: [
{
title: '群智协作',
subtitle: '多智能体协同',
detailTitle: '慧投标智能体',
detailSubTitle: '基于 Agent+RAG+LangGraph 多节点编排,实现招投标全流程 AI 化',
detailDesc: '从标书解读、资质匹配到内容生成,多智能体协同提升投标作业效率。',
detailTitle: '群智协作',
detailSubTitle: '多智能体协同',
detailDesc: '多个AI智能体按角色分工自主完成信息采集、数据分析、逻辑推理与结论生成的全流程协作。支持动态任务分配与上下文共享将复杂业务决策从"人串联"变为"智能体并行",大幅压缩高知识密度工作的完成周期。',
metrics: [
{ value: '10倍+', label: '效率提升' },
{ value: '80%', label: '废标率降低' },
{ value: '35%', label: '中标率提升' }
{ value: '10倍+', label: '知识工作效率提升' },
{ value: '多源', label: '信息实时融合' },
{ value: '高复杂度', label: '决策场景适配' }
],
bg: 'linear-gradient(145deg, rgba(208, 236, 249, 0.72) 0%, rgba(168, 216, 244, 0.64) 44%, rgba(124, 196, 238, 0.58) 100%)',
shadow: '0 18px 54px rgba(80, 150, 220, 0.2), 0 4px 16px rgba(80, 150, 220, 0.08)',
icon: '<svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="24" cy="18" r="6"/><circle cx="10" cy="34" r="5"/><circle cx="38" cy="34" r="5"/><line x1="20" y1="23" x2="13" y2="30"/><line x1="28" y1="23" x2="35" y2="30"/><circle cx="36" cy="14" r="3.5"/><circle cx="12" cy="14" r="3.5"/></svg>'
},
{
title: '空间觉醒',
subtitle: 'GIS智能分析',
detailTitle: '空间觉醒',
detailSubTitle: 'GIS智能分析',
detailDesc: '融合地理信息系统、卫星遥感影像、无人机航拍等多维空间数据AI自动识别地物边界、叠加分析空间关系、校验合规约束。将传统需要人工逐层比对的地理空间判断升级为分钟级自动完成的空间智能决策。',
metrics: [
{ value: '分钟级', label: '空间分析' },
{ value: '90%+', label: '违规识别准确率' },
{ value: '70%', label: '人力成本节省' }
],
bg: 'linear-gradient(145deg, rgba(200, 238, 218, 0.72) 0%, rgba(152, 216, 184, 0.64) 44%, rgba(104, 194, 150, 0.58) 100%)',
shadow: '0 18px 54px rgba(60, 150, 100, 0.18), 0 4px 16px rgba(60, 150, 100, 0.08)',
icon: '<svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="24" cy="24" r="18"/><ellipse cx="24" cy="24" rx="10" ry="18"/><line x1="6" y1="24" x2="42" y2="24"/><path d="M8 16h32"/><path d="M8 32h32"/><path d="M24 6v36"/></svg>'
},
{
title: '先见一步',
subtitle: '智能预测引擎',
detailTitle: '先见一步',
detailSubTitle: '智能预测引擎',
detailDesc: '融合机理模型与数据驱动模型通过实时采集设备运行参数AI自主识别早期异常特征并精准预测剩余使用寿命。在故障真正发生前72小时发出预警让运维从"事后抢修"转变为"事前防御",最大化保障生产连续性。',
metrics: [
{ value: '95%+', label: '故障预警准确率' },
{ value: '60%', label: '非计划停机减少' },
{ value: '72小时', label: '预测提前量' }
],
bg: 'linear-gradient(145deg, rgba(214, 240, 224, 0.72) 0%, rgba(168, 222, 190, 0.64) 44%, rgba(122, 204, 158, 0.58) 100%)',
shadow: '0 18px 54px rgba(60, 150, 100, 0.18), 0 4px 16px rgba(60, 150, 100, 0.08)',
icon: '<svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M6 38l10-12 8 6 10-16 8 10"/><polyline points="36,22 42,26 38,32"/><line x1="6" y1="40" x2="42" y2="40"/><path d="M6 8v32"/></svg>'
},
{
title: '洞察入微',
subtitle: '机器视觉检测',
detailTitle: '洞察入微',
detailSubTitle: '机器视觉检测',
detailDesc: '基于深度学习的高速产线全幅面缺陷检测能力覆盖表面处理、涂层喷涂、焊接装配等全流程质检环节。不是抽样检查而是每一寸产品都经过AI逐像素比对在毫秒级时间内完成缺陷识别与分类确保出厂品质零妥协。',
metrics: [
{ value: '毫秒级', label: '检测速度' },
{ value: '95%', label: '漏检率降低' },
{ value: '99.5%+', label: '识别准确率' }
],
bg: 'linear-gradient(145deg, rgba(192, 222, 239, 0.72) 0%, rgba(136, 194, 224, 0.64) 44%, rgba(80, 166, 208, 0.58) 100%)',
shadow: '0 18px 54px rgba(40, 110, 170, 0.18), 0 4px 16px rgba(40, 110, 170, 0.08)',
icon: '<svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 24s8-14 20-14 20 14 20 14-8 14-20 14S4 24 4 24z"/><circle cx="24" cy="24" r="7"/><circle cx="24" cy="24" r="3"/><line x1="24" y1="6" x2="24" y2="10"/><line x1="24" y1="38" x2="24" y2="42"/></svg>'
},
{
title: '一问即达',
subtitle: '智能问答知识库',
detailTitle: '智能问答知识库',
detailSubTitle: '燃机 AI 诊断助手',
detailDesc: '融合专家知识库,支持中英文自然语言多轮对话,自动生成诊断建议。',
detailTitle: '一问即达',
detailSubTitle: '智能问答知识库',
detailDesc: '基于企业私有知识库构建的AI问答引擎支持自然语言直接查询业务数据、技术规范、制度文件。每条回答自动标注权威来源信息可追溯、可验证。让专家级知识不再锁在文档里而是像问同事一样即问即答。',
metrics: [
{ value: '<3s', label: '诊断响应' },
{ value: '90%', label: '知识覆盖' },
{ value: '<3秒', label: '响应速度' },
{ value: '90%', label: '专家知识覆盖' },
{ value: '5倍', label: '决策效率提升' }
],
bg: 'linear-gradient(145deg, rgba(224, 208, 242, 0.72) 0%, rgba(200, 176, 230, 0.64) 44%, rgba(170, 142, 216, 0.58) 100%)',
shadow: '0 18px 54px rgba(130, 80, 180, 0.18), 0 4px 16px rgba(130, 80, 180, 0.08)',
icon: '<svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="6" y="8" width="36" height="28" rx="4"/><path d="M6 16h36"/><circle cx="24" cy="28" r="3"/><path d="M18 28h1"/><path d="M29 28h1"/><path d="M18 42h12"/></svg>'
},
{
title: '空间觉醒',
subtitle: 'GIS智能分析',
detailTitle: '林业采伐空间智审',
detailSubTitle: 'AI+GIS 深度融合,智能识别禁伐区、历史采伐点',
detailDesc: '输出合规性报告,辅助采伐审批与空间治理。',
metrics: [
{ value: '分钟级', label: '审批周期' },
{ value: '90%', label: '违规识别提升' },
{ value: '70%', label: '节省人力' }
],
bg: 'linear-gradient(145deg, rgba(200, 238, 218, 0.72) 0%, rgba(152, 216, 184, 0.64) 44%, rgba(104, 194, 150, 0.58) 100%)',
shadow: '0 18px 54px rgba(60, 150, 100, 0.18), 0 4px 16px rgba(60, 150, 100, 0.08)',
icon: '<svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="24" cy="24" r="18"/><ellipse cx="24" cy="24" rx="10" ry="18"/><line x1="6" y1="24" x2="42" y2="24"/><path d="M8 16h32"/><path d="M8 32h32"/><path d="M24 6v36"/></svg>'
},
{
title: '全程守护',
subtitle: '设备智能监控',
detailTitle: '三维可视化监盘系统',
detailSubTitle: '自动告警高亮定位,降低运维人员技能门槛',
detailDesc: '一机一策三维可视化,实时监控设备关键点位。',
detailTitle: '全程守护',
detailSubTitle: '设备智能监控',
detailDesc: '为每台设备构建独立监控策略通过三维可视化实时呈现运行状态。AI自动识别异常并高亮定位故障点同步输出详细诊断建议和排查步骤。将运维人员从"盯屏找问题"解放出来,让系统主动告诉你问题在哪、怎么修。',
metrics: [
{ value: '1000+', label: '监控点位' },
{ value: '<1s', label: '告警响应' },
{ value: '40%', label: '成本降低' }
{ value: '1000+', label: '监控点位支持' },
{ value: '<1秒', label: '告警响应' },
{ value: '40%', label: '运维成本降低' }
],
bg: 'linear-gradient(145deg, rgba(251, 230, 200, 0.72) 0%, rgba(245, 205, 160, 0.64) 44%, rgba(238, 180, 120, 0.58) 100%)',
shadow: '0 18px 54px rgba(190, 130, 50, 0.18), 0 4px 16px rgba(190, 130, 50, 0.08)',
icon: '<svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="8" y="10" width="32" height="22" rx="3"/><path d="M18 36h12"/><line x1="24" y1="32" x2="24" y2="36"/><path d="M16 20l4 4 6-8"/><circle cx="38" cy="12" r="4"/></svg>'
},
{
title: '先见一步',
subtitle: '智能预测引擎',
detailTitle: '燃机智慧监盘',
detailSubTitle: 'AI 预测引擎+TDengine 时序数据库',
detailDesc: '从被动响应到主动预警,保障设备稳定运行。',
metrics: [
{ value: '95%+', label: '预警准确率' },
{ value: '60%', label: '停机时间减少' },
{ value: '72h', label: '提前预警' }
],
bg: 'linear-gradient(145deg, rgba(214, 240, 224, 0.72) 0%, rgba(168, 222, 190, 0.64) 44%, rgba(122, 204, 158, 0.58) 100%)',
shadow: '0 18px 54px rgba(60, 150, 100, 0.18), 0 4px 16px rgba(60, 150, 100, 0.08)',
icon: '<svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M6 38l10-12 8 6 10-16 8 10"/><polyline points="36,22 42,26 38,32"/><line x1="6" y1="40" x2="42" y2="40"/><path d="M6 8v32"/></svg>'
},
{
title: '触文即懂',
subtitle: '智能文档解析',
detailTitle: '招标文件智能解析',
detailSubTitle: 'AI 自动读取招标文件,结构化提取关键信息',
detailDesc: '精准识别资质要求、评分标准和关键条款,辅助人工快速审查。',
detailTitle: '触文即懂',
detailSubTitle: '智能文档解析',
detailDesc: '支持PDF、Word、扫描件等多种格式一键解析AI自动提取关键条款、资质要求、评分细则等核心信息并结构化呈现。不是简单的OCR文字识别而是理解文档语义、判断信息重要性、标注风险点让冗长文档的核心内容一目了然。',
metrics: [
{ value: '98%+', label: '解析准确率' },
{ value: '85%', label: '核查时间减少' },
{ value: '0漏', label: '关键信息' }
{ value: '85%', label: '人工核查时间减少' },
{ value: '零遗漏', label: '关键信息' }
],
bg: 'linear-gradient(145deg, rgba(200, 214, 242, 0.72) 0%, rgba(158, 180, 230, 0.64) 44%, rgba(116, 146, 216, 0.58) 100%)',
shadow: '0 18px 54px rgba(60, 80, 160, 0.18), 0 4px 16px rgba(60, 80, 160, 0.08)',
icon: '<svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 6h16l10 10v26a4 4 0 0 1-4 4H12a4 4 0 0 1-4-4V10a4 4 0 0 1 4-4z"/><polyline points="28,6 28,16 38,16"/><line x1="14" y1="24" x2="34" y2="24"/><line x1="14" y1="31" x2="30" y2="31"/></svg>'
},
{
title: '洞察入微',
subtitle: '机器视觉检测',
detailTitle: '零碳铝板缺陷检测',
detailSubTitle: 'YOLO 目标检测+TensorRT 边缘推理',
detailDesc: '毫秒级实时质检,替代人工目检,提升产线检测稳定性。',
metrics: [
{ value: 'ms级', label: '检测速度' },
{ value: '95%+', label: '漏检率降低' },
{ value: '25%', label: '产能提升' }
],
bg: 'linear-gradient(145deg, rgba(192, 222, 239, 0.72) 0%, rgba(136, 194, 224, 0.64) 44%, rgba(80, 166, 208, 0.58) 100%)',
shadow: '0 18px 54px rgba(40, 110, 170, 0.18), 0 4px 16px rgba(40, 110, 170, 0.08)',
icon: '<svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 24s8-14 20-14 20 14 20 14-8 14-20 14S4 24 4 24z"/><circle cx="24" cy="24" r="7"/><circle cx="24" cy="24" r="3"/><line x1="24" y1="6" x2="24" y2="10"/><line x1="24" y1="38" x2="24" y2="42"/></svg>'
},
{
title: '端侧即算',
subtitle: '边缘AI推理',
detailTitle: '边缘实时质检',
detailSubTitle: 'TensorRT 优化模型,边缘端毫秒级推理',
detailDesc: '无需上传云端,保障数据安全并适配 7x24 小时连续运行。',
detailTitle: '端侧即算',
detailSubTitle: '边缘AI推理',
detailDesc: '将AI推理能力部署在生产现场边缘侧通过轻量化算法实现毫秒级实时决策。数据无需回传云端在本地即可完成从感知到判断的全流程确保数据不出厂、响应零延迟。冗余架构设计保障7×24小时持续运行产线检测永不中断。',
metrics: [
{ value: '<50ms', label: '推理延迟' },
{ value: '本地', label: '数据不出厂' },
{ value: '7x24', label: '持续运行' }
{ value: '本地', label: '数据处理不出厂' },
{ value: '7×24h', label: '持续运行' }
],
bg: 'linear-gradient(145deg, rgba(216, 204, 240, 0.72) 0%, rgba(184, 164, 226, 0.64) 44%, rgba(150, 124, 212, 0.58) 100%)',
shadow: '0 18px 54px rgba(90, 60, 160, 0.18), 0 4px 16px rgba(90, 60, 160, 0.08)',
@ -372,17 +358,7 @@ export default {
})
},
localizedNewsList() {
const keys = ['first', 'second']
return this.newsList.map((item, index) => {
const key = keys[index]
if (!key) return item
return {
...item,
tag: this.$t('home.news.tag'),
title: this.$t(`home.news.${key}Title`),
desc: this.$t(`home.news.${key}Desc`)
}
})
return this.newsList
},
visibleSolutionCards() {
const slots = [-3, -2, -1, 0, 1, 2, 3]
@ -408,6 +384,7 @@ export default {
},
mounted() {
this.startSolutionCarousel()
this.getHomeNewsList()
},
beforeDestroy() {
this.stopSolutionCarousel()
@ -442,6 +419,48 @@ export default {
goNews() {
this.navigateTo('/homePage/news')
},
buildHomeNewsParams() {
return {
url_link: window.location.href,
current_page: '1',
page_size: '2',
title: '',
article_type: '',
status: '1'
}
},
getNewsTagClass(index) {
return index % 2 === 0 ? 'news-tag--blue' : 'news-tag--green'
},
normalizeHomeNewsItem(row, index) {
return {
id: row.id,
tag: row.article_type || this.$t('home.news.tag'),
tagClass: this.getNewsTagClass(index),
date: row.publish_time || row.publishTime || '',
title: row.title || '',
desc: row.summary || row.desc || row.description || ''
}
},
getResponseList(res) {
if (Array.isArray(res.data)) return res.data
if (res.data && Array.isArray(res.data.data)) return res.data.data
if (res.data && Array.isArray(res.data.list)) return res.data.list
if (Array.isArray(res.list)) return res.list
return []
},
async getHomeNewsList() {
try {
const res = await reqNewsList(this.buildHomeNewsParams())
if (res && (res.status === true || res.status === 'true')) {
this.newsList = this.getResponseList(res)
.slice(0, 2)
.map((item, index) => this.normalizeHomeNewsItem(item, index))
}
} catch (error) {
this.newsList = []
}
},
nextSolution() {

View File

@ -0,0 +1,592 @@
<template>
<el-dialog
:visible.sync="dialogVisible"
:title="dialogTitle"
:fullscreen="true"
append-to-body
custom-class="article-editor-dialog"
:close-on-click-modal="false"
@open="handleOpen"
>
<div class="article-editor">
<div class="article-editor__left">
<div class="article-editor__form">
<el-form :model="form" label-width="76px" size="small">
<el-form-item label="文章标题">
<el-input v-model.trim="form.title" placeholder="请输入文章标题" />
</el-form-item>
<el-form-item label="文章摘要">
<el-input
v-model.trim="form.summary"
type="textarea"
:rows="2"
maxlength="120"
show-word-limit
placeholder="请输入文章摘要"
/>
</el-form-item>
<div class="article-editor__inline">
<el-form-item label="文章类型">
<el-select v-model="form.type" placeholder="请选择文章类型">
<el-option label="企业动态" value="企业动态" />
<el-option label="产品动态" value="产品动态" />
<el-option label="行业洞察" value="行业洞察" />
<el-option label="活动资讯" value="活动资讯" />
</el-select>
</el-form-item>
<el-form-item label="发布时间">
<el-date-picker
v-model="form.publishTime"
type="date"
value-format="yyyy-MM-dd"
placeholder="选择发布时间"
/>
</el-form-item>
</div>
<el-form-item label="封面配图">
<div class="cover-row">
<el-button type="primary" size="small" @click="$refs.coverInput.click()">上传图片</el-button>
<el-input
v-model.trim="form.coverUrl"
class="cover-url-input"
placeholder="可填写图片地址,或点击左侧上传本地图片"
@input="handleCoverUrlInput"
/>
<span v-if="form.coverName" class="cover-name">{{ form.coverName }}</span>
<input ref="coverInput" type="file" accept="image/*" class="hidden-input" @change="handleCoverUpload">
</div>
</el-form-item>
</el-form>
</div>
<div v-if="dialogVisible" class="wang-editor-wrap">
<Toolbar
class="wang-toolbar"
:editor="editor"
:default-config="toolbarConfig"
:mode="editorMode"
/>
<Editor
v-model="form.content"
class="wang-editor"
:default-config="editorConfig"
:mode="editorMode"
@onCreated="handleEditorCreated"
/>
</div>
</div>
<div
class="resize-handle"
title="拖动调整预览宽度"
@mousedown="startResize"
></div>
<div class="article-editor__preview" :style="{ width: `${previewWidth}px` }">
<div class="preview-head">
<span>实时预览</span>
<em>自动同步</em>
</div>
<div class="preview-card">
<div v-if="coverImage" class="preview-cover">
<img :src="coverImage" alt="cover">
</div>
<span class="preview-type">{{ form.type || '企业动态' }}</span>
<h1>{{ form.title || '文章标题' }}</h1>
<p class="preview-summary">{{ form.summary || '文章摘要预览...' }}</p>
<p class="preview-date">{{ form.publishTime || today }}</p>
<div class="preview-content" v-html="form.content || '文章内容预览...'"></div>
</div>
</div>
</div>
<div slot="footer" class="article-editor-footer">
<span class="editor-status">{{ dirty ? '未保存' : '已同步' }}</span>
<div>
<el-button size="small" @click="dialogVisible = false">取消</el-button>
<el-button size="small" @click="handleSave('draft')">保存草稿</el-button>
<el-button type="primary" size="small" @click="handleSave('published')">发布文章</el-button>
</div>
</div>
</el-dialog>
</template>
<script>
import '@wangeditor/editor/dist/css/style.css'
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
const normalizeImageUrl = (url) => {
if (!url) return ''
if (/^(https?:|blob:|data:|\/\/)/.test(url)) return url
return `${window.location.origin}/idfile?path=${url}`
}
export default {
name: 'ArticleEditorDialog',
components: { Editor, Toolbar },
props: {
visible: {
type: Boolean,
default: false
},
article: {
type: Object,
default: null
}
},
data() {
return {
//
dirty: false,
// wangEditor
editor: null,
// wangEditor
editorMode: 'default',
// wangEditor
toolbarConfig: {},
// wangEditor
editorConfig: {
placeholder: '请输入文章内容...',
MENU_CONF: {
uploadImage: {
customUpload: (file, insertFn) => {
// File FormData
this.form.contentFiles.push(file)
//
const url = URL.createObjectURL(file)
insertFn(url, file.name, url)
}
}
}
},
//
coverPreview: '',
//
previewWidth: 400,
//
resizing: false,
//
resizeStartX: 0,
//
resizeStartWidth: 400,
//
form: this.createEmptyForm()
}
},
computed: {
dialogVisible: {
get() {
// 使 visible
return this.visible
},
set(value) {
// sync visible
this.$emit('update:visible', value)
}
},
dialogTitle() {
//
return this.article ? '编辑文章' : '新建文章'
},
today() {
//
const date = new Date()
const month = `${date.getMonth() + 1}`.padStart(2, '0')
const day = `${date.getDate()}`.padStart(2, '0')
return `${date.getFullYear()}-${month}-${day}`
},
coverImage() {
// 使使/
if (this.coverPreview) return this.coverPreview
return normalizeImageUrl(this.form.coverUrl)
}
},
watch: {
form: {
deep: true,
handler() {
//
this.dirty = true
}
}
},
beforeDestroy() {
// wangEditor
if (this.editor) {
this.editor.destroy()
this.editor = null
}
//
this.removeResizeListeners()
},
methods: {
createEmptyForm() {
// 使
return {
id: '',
title: '',
summary: '',
type: '企业动态',
status: '0',
publishTime: '',
views: 0,
content: '',
contentFiles: [],
coverUrl: '',
coverFile: null,
coverName: ''
}
},
handleOpen() {
//
this.form = {
...this.createEmptyForm(),
...(this.article || {})
}
// 使
if (!this.form.publishTime) this.form.publishTime = this.today
//
this.coverPreview = ''
this.$nextTick(() => {
//
this.dirty = false
})
},
handleEditorCreated(editor) {
// 使
this.editor = Object.seal(editor)
},
handleCoverUpload(event) {
//
const file = event.target.files && event.target.files[0]
if (!file) return
//
this.form.coverName = file.name
// FormData cover_img
this.form.coverFile = file
//
this.coverPreview = URL.createObjectURL(file)
// input change
event.target.value = ''
},
handleCoverUrlInput() {
//
this.coverPreview = ''
this.form.coverFile = null
this.form.coverName = ''
},
startResize(event) {
//
this.resizing = true
//
this.resizeStartX = event.clientX
//
this.resizeStartWidth = this.previewWidth
//
document.body.style.cursor = 'col-resize'
//
document.body.style.userSelect = 'none'
//
window.addEventListener('mousemove', this.handleResize)
window.addEventListener('mouseup', this.stopResize)
},
handleResize(event) {
//
if (!this.resizing) return
//
const delta = this.resizeStartX - event.clientX
const nextWidth = this.resizeStartWidth + delta
//
this.previewWidth = Math.min(Math.max(nextWidth, 320), 720)
},
stopResize() {
//
this.resizing = false
//
document.body.style.cursor = ''
//
document.body.style.userSelect = ''
//
this.removeResizeListeners()
},
removeResizeListeners() {
//
window.removeEventListener('mousemove', this.handleResize)
//
window.removeEventListener('mouseup', this.stopResize)
},
handleSave(status) {
//
if (!this.form.title) {
this.$message.warning('请输入文章标题')
return
}
//
if (!this.form.summary) {
this.$message.warning('请输入文章摘要')
return
}
// publish_time
if (!this.form.publishTime) {
this.$message.warning('请选择发布时间')
return
}
// cover_img
if (!this.form.coverFile && !this.form.coverUrl) {
this.$message.warning('请上传封面图片或填写图片地址')
return
}
//
if (!this.form.content) {
this.$message.warning('请输入文章正文')
return
}
// status 稿
const payload = {
...this.form,
publish_time: this.form.publishTime,
status
}
//
this.$emit('save', payload)
//
this.dirty = false
//
this.dialogVisible = false
}
}
}
</script>
<style scoped>
::v-deep .article-editor-dialog {
margin: 0 !important;
height: 100vh;
display: flex;
flex-direction: column;
}
::v-deep .article-editor-dialog .el-dialog__header {
flex-shrink: 0;
padding: 16px 24px;
border-bottom: 1px solid #edf0f5;
}
::v-deep .article-editor-dialog .el-dialog__body {
flex: 1;
min-height: 0;
padding: 0 24px;
overflow: hidden;
}
::v-deep .article-editor-dialog .el-dialog__footer {
flex-shrink: 0;
padding: 14px 24px;
border-top: 1px solid #edf0f5;
}
.article-editor {
height: 100%;
display: flex;
overflow: hidden;
}
.article-editor__left {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.resize-handle {
position: relative;
flex: 0 0 10px;
width: 10px;
cursor: col-resize;
background: #f8fafc;
border-left: 1px solid #edf0f5;
border-right: 1px solid #edf0f5;
transition: background 0.2s ease;
}
.resize-handle::before {
position: absolute;
top: 50%;
left: 50%;
width: 3px;
height: 42px;
content: '';
border-radius: 999px;
background: #cbd5e1;
transform: translate(-50%, -50%);
transition: background 0.2s ease, height 0.2s ease;
}
.resize-handle:hover {
background: #eff6ff;
}
.resize-handle:hover::before {
height: 58px;
background: #3b82f6;
}
.article-editor__form {
padding: 16px 18px 4px 0;
border-bottom: 1px solid #edf0f5;
}
.article-editor__inline {
display: flex;
gap: 16px;
}
.article-editor__inline .el-form-item {
flex: 1;
}
.cover-row {
display: flex;
align-items: center;
gap: 12px;
}
.cover-url-input {
flex: 1;
}
.cover-name {
color: #16a34a;
font-size: 13px;
}
.hidden-input {
display: none;
}
.article-editor__toolbar-tip {
padding: 10px 18px 10px 0;
color: #8a94a6;
font-size: 12px;
}
.wang-editor-wrap {
flex: 1;
min-height: 0;
padding-right: 18px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.wang-toolbar {
border: 1px solid #edf0f5;
border-bottom: 0;
border-radius: 10px 10px 0 0;
}
.wang-editor {
flex: 1;
min-height: 0;
border: 1px solid #edf0f5;
border-radius: 0 0 10px 10px;
overflow-y: auto;
}
.article-editor__preview {
flex: 0 0 auto;
display: flex;
flex-direction: column;
background: #f8fafc;
}
.preview-head {
height: 48px;
padding: 0 18px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #edf0f5;
}
.preview-head span {
color: #344054;
font-size: 14px;
font-weight: 600;
}
.preview-head em {
color: #9ca3af;
font-size: 12px;
font-style: normal;
}
.preview-card {
flex: 1;
min-height: 0;
margin: 18px;
padding: 24px;
overflow-y: auto;
background: #fff;
border-radius: 12px;
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.06);
}
.preview-cover {
margin-bottom: 16px;
}
.preview-cover img {
width: 100%;
border-radius: 10px;
}
.preview-type {
display: inline-flex;
padding: 2px 8px;
margin-bottom: 10px;
color: #2563eb;
font-size: 12px;
background: #eff6ff;
border-radius: 999px;
}
.preview-card h1 {
margin: 0 0 8px;
color: #1f2937;
font-size: 30px;
font-weight: 700;
line-height: 1.4;
}
.preview-date {
margin: 0 0 18px;
color: #9ca3af;
font-size: 12px;
}
.preview-summary {
margin: 0 0 10px;
color: #667085;
font-size: 14px;
line-height: 1.7;
}
.preview-content {
color: #4b5563;
font-size: 14px;
line-height: 1.8;
}
.article-editor-footer {
display: flex;
justify-content: space-between;
align-items: center;
}
.editor-status {
color: #9ca3af;
font-size: 12px;
}
</style>

View File

@ -0,0 +1,719 @@
<template>
<div class="article-admin-page">
<div class="page-title-wrap">
<h1>企业文章</h1>
</div>
<div class="stats-grid">
<div
v-for="item in statsCards"
:key="item.type"
class="stats-card"
>
<div class="stats-icon" :class="item.iconClass">
<i :class="item.icon"></i>
</div>
<div>
<div class="stats-label">{{ item.label }}</div>
<div class="stats-value">{{ item.count }} </div>
<div class="stats-read">阅读 {{ item.views }}</div>
</div>
</div>
</div>
<div class="filter-card">
<el-form :model="queryForm" inline class="filter-form">
<el-form-item label="文章类型">
<el-select v-model="queryForm.type" size="small" clearable placeholder="全部类型" @change="handleSearch">
<el-option label="企业动态" value="企业动态" />
<el-option label="产品动态" value="产品动态" />
<el-option label="行业洞察" value="行业洞察" />
<el-option label="活动资讯" value="活动资讯" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="queryForm.status" size="small" clearable placeholder="全部状态" @change="handleSearch">
<el-option label="已发布" value="published" />
<el-option label="草稿" value="draft" />
</el-select>
</el-form-item>
<el-form-item>
<el-input
v-model.trim="queryForm.keyword"
size="small"
clearable
placeholder="搜索文章标题..."
@keyup.enter.native="handleSearch"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" size="small" icon="el-icon-search" @click="handleSearch">搜索</el-button>
<el-button type="primary" size="small" icon="el-icon-plus" @click="openArticleEditor">新建文章</el-button>
</el-form-item>
</el-form>
</div>
<div class="table-card">
<el-table
:data="pagedArticles"
v-loading="loading"
class="article-table"
style="width: 100%"
empty-text="暂无文章,点击“新建文章”开始创作"
>
<el-table-column label="序号" width="80">
<template slot-scope="scope">
{{ getRowNo(scope.$index) }}
</template>
</el-table-column>
<el-table-column prop="title" label="标题" min-width="260" show-overflow-tooltip>
<template slot-scope="scope">
<span class="article-title">{{ scope.row.title }}</span>
</template>
</el-table-column>
<el-table-column label="类型" width="120">
<template slot-scope="scope">
<span class="type-tag" :class="getTypeClass(scope.row.type)">{{ scope.row.type }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="110">
<template slot-scope="scope">
<span class="status-tag" :class="scope.row.status === 'published' ? 'is-published' : 'is-draft'">
<i></i>{{ scope.row.status === 'published' ? '已发布' : '草稿' }}
</span>
</template>
</el-table-column>
<el-table-column prop="publishTime" label="发布时间" width="140" />
<el-table-column label="阅读数据" width="130">
<template slot-scope="scope">
<span v-if="scope.row.status === 'published'" class="views-text">
<i class="el-icon-view"></i>{{ scope.row.views }}
</span>
<span v-else class="empty-read">-</span>
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template slot-scope="scope">
<el-button
type="text"
size="mini"
:disabled="scope.row.status === 'published'"
@click="editArticle(scope.row)"
>
编辑
</el-button>
<el-button
v-if="scope.row.status === 'published'"
type="text"
size="mini"
class="warning-text"
@click="unpublishArticle(scope.row)"
>
下架
</el-button>
<el-button
v-else
type="text"
size="mini"
class="success-text"
@click="publishArticle(scope.row)"
>
上架
</el-button>
<el-button
type="text"
size="mini"
class="danger-text"
:disabled="scope.row.status === 'published'"
@click="deleteArticle(scope.row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<div class="table-pagination">
<span class="pagination-info">显示 {{ pageStart }}-{{ pageEnd }} {{ total }} </span>
<el-pagination
background
layout="prev, pager, next, jumper"
:current-page.sync="page"
:page-size="pageSize"
:total="total"
@current-change="handlePageChange"
/>
</div>
</div>
<article-editor-dialog
:visible.sync="editorVisible"
:article="currentArticle"
@save="handleSaveArticle"
/>
</div>
</template>
<script>
import ArticleEditorDialog from './ArticleEditorDialog.vue'
import {
reqAddNews,
reqDeleteNews,
reqEditNews,
reqNewsListAdmin,
reqPublishNews,
reqUnpublishNews
} from '@/api/newsapi/newsapi'
export default {
name: 'NewsLog',
components: { ArticleEditorDialog },
data() {
return {
page: 1,
pageSize: 10,
total: 0,
loading: false,
editorVisible: false,
currentArticle: null,
queryForm: {
type: '',
status: '',
keyword: ''
},
articleList: []
}
},
computed: {
filteredArticles() {
return this.articleList
},
pagedArticles() {
return this.articleList
},
pageStart() {
if (!this.total) return 0
return (this.page - 1) * this.pageSize + 1
},
pageEnd() {
if (!this.total) return 0
return Math.min(this.page * this.pageSize, this.total)
},
statsCards() {
const map = {
企业动态: { label: '企业动态', type: '企业动态', icon: 'el-icon-document', iconClass: 'blue' },
产品动态: { label: '产品动态', type: '产品动态', icon: 'el-icon-box', iconClass: 'green' },
行业洞察: { label: '行业洞察', type: '行业洞察', icon: 'el-icon-data-analysis', iconClass: 'amber' },
活动资讯: { label: '活动资讯', type: '活动资讯', icon: 'el-icon-date', iconClass: 'purple' }
}
return Object.values(map).map(item => {
const list = this.articleList.filter(article => article.type === item.type)
return {
...item,
count: list.length,
views: list.reduce((total, article) => total + Number(article.views || 0), 0)
}
})
}
},
created() {
//
this.getArticleList()
},
methods: {
getRowNo(index) {
//
return (this.page - 1) * this.pageSize + index + 1
},
getTypeClass(type) {
//
const classMap = {
企业动态: 'type-blue',
产品动态: 'type-purple',
行业洞察: 'type-amber',
活动资讯: 'type-violet'
}
return classMap[type] || 'type-blue'
},
handleSearch() {
//
this.page = 1
this.getArticleList()
},
handlePageChange(page) {
//
this.page = page
this.getArticleList()
},
openArticleEditor() {
//
this.currentArticle = null
this.editorVisible = true
},
editArticle(row) {
//
this.currentArticle = { ...row }
this.editorVisible = true
},
getApiStatus(status) {
// 1 0 稿/
return status === 'published' ? '1' : '0'
},
getViewStatus(status) {
// 使
return String(status) === '1' || status === 'published' ? 'published' : 'draft'
},
isSuccess(res) {
//
return res && (res.status === true || res.status === 'true' || res.code === 200)
},
buildQueryParams() {
//
return {
url_link: window.location.href,
current_page: String(this.page),
page_size: String(this.pageSize),
title: this.queryForm.keyword.trim(),
article_type: this.queryForm.type,
status: this.queryForm.status ? this.getApiStatus(this.queryForm.status) : ''
}
},
normalizeArticle(row) {
// 使
return {
id: row.id,
title: row.title || '',
summary: row.summary || '',
type: row.article_type || row.type || '企业动态',
status: this.getViewStatus(row.status),
publishTime: row.publish_time || row.publishTime || '',
views: row.read_count || row.views || 0,
content: row.content || '',
coverUrl: row.cover_img || row.coverUrl || '',
coverName: row.cover_name || row.coverName || ''
}
},
getResponseList(res) {
//
if (Array.isArray(res.data)) return res.data
if (res.data && Array.isArray(res.data.data)) return res.data.data
if (res.data && Array.isArray(res.data.list)) return res.data.list
if (Array.isArray(res.list)) return res.list
return []
},
getResponseTotal(res, list) {
//
const data = res.data && !Array.isArray(res.data) ? res.data : {}
const pagination = res.pagination || data.pagination || data.page || {}
return Number(
res.total ||
res.total_count ||
data.total ||
data.total_count ||
pagination.total ||
list.length
)
},
buildArticlePayload(article) {
// /
const payload = {
...(article.id ? { id: article.id } : {}),
url_link: window.location.href,
title: article.title,
article_type: article.type,
summary: article.summary,
publish_time: article.publish_time || article.publishTime,
content: article.content,
status: this.getApiStatus(article.status)
}
const hasContentFiles = Array.isArray(article.contentFiles) && article.contentFiles.length > 0
if (article.coverFile || hasContentFiles) {
const formData = new FormData()
Object.keys(payload).forEach(key => {
formData.append(key, payload[key])
})
if (article.coverFile) {
formData.append('cover_img', article.coverFile)
} else {
formData.append('cover_img', article.coverUrl)
}
if (hasContentFiles) {
article.contentFiles.forEach(file => {
formData.append('content_images', file)
})
}
return formData
}
return {
...payload,
cover_img: article.coverUrl
}
},
async confirmAction(title, message, type = 'warning') {
//
try {
await this.$confirm(message, title, {
confirmButtonText: '确定',
cancelButtonText: '取消',
type
})
return true
} catch (error) {
return false
}
},
async getArticleList() {
// loading
this.loading = true
try {
//
const res = await reqNewsListAdmin(this.buildQueryParams())
if (this.isSuccess(res)) {
//
const list = this.getResponseList(res)
//
this.articleList = list.map(item => this.normalizeArticle(item))
//
this.total = this.getResponseTotal(res, list)
return
}
//
this.articleList = []
this.total = 0
} catch (error) {
//
this.articleList = []
this.total = 0
this.$message.error('文章列表加载失败')
} finally {
// loading
this.loading = false
}
},
async handleSaveArticle(article) {
// id
const request = article.id ? reqEditNews : reqAddNews
try {
//
const res = await request(this.buildArticlePayload(article))
if (!this.isSuccess(res)) {
//
this.$message.error((res && (res.message || res.msg)) || '保存失败')
return
}
//
this.$message.success(article.status === 'published' ? '发布成功' : '草稿已保存')
this.getArticleList()
} catch (error) {
//
this.$message.error('保存失败')
}
},
async publishArticle(row) {
//
const confirmed = await this.confirmAction('确认上架', `确定要上架「${row.title}」吗?`)
if (!confirmed) return
try {
//
const res = await reqPublishNews({ id: row.id })
if (!this.isSuccess(res)) {
//
this.$message.error((res && (res.message || res.msg)) || '发布失败')
return
}
//
this.$message.success('上架成功')
this.getArticleList()
} catch (error) {
//
this.$message.error('上架失败')
}
},
async unpublishArticle(row) {
//
const confirmed = await this.confirmAction('确认下架', `确定要下架「${row.title}」吗?`)
if (!confirmed) return
try {
//
const res = await reqUnpublishNews({ id: row.id })
if (!this.isSuccess(res)) {
//
this.$message.error((res && (res.message || res.msg)) || '下架失败')
return
}
//
this.$message.success('下架成功')
this.getArticleList()
} catch (error) {
//
this.$message.error('下架失败')
}
},
async deleteArticle(row) {
//
const confirmed = await this.confirmAction('确认删除', `删除后不可恢复,确定要删除「${row.title}」吗?`, 'error')
if (!confirmed) return
try {
//
const res = await reqDeleteNews({ id: row.id })
if (!this.isSuccess(res)) {
//
this.$message.error((res && (res.message || res.msg)) || '删除失败')
return
}
//
if (this.articleList.length === 1 && this.page > 1) this.page -= 1
//
this.$message.success('删除成功')
this.getArticleList()
} catch (error) {
//
this.$message.error('删除失败')
}
}
}
}
</script>
<style scoped>
.article-admin-page {
min-height: calc(100vh - 84px);
padding: 24px;
background: #f3f7ff;
}
.page-title-wrap {
margin-bottom: 24px;
}
.page-title-wrap h1 {
margin: 0;
color: #1f2937;
font-size: 28px;
font-weight: 700;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.stats-card {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
background: #fff;
border: 1px solid #edf0f5;
border-radius: 14px;
}
.stats-icon {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 10px;
font-size: 20px;
}
.stats-icon.blue {
color: #3b82f6;
background: #eff6ff;
}
.stats-icon.green {
color: #10b981;
background: #ecfdf5;
}
.stats-icon.amber {
color: #f59e0b;
background: #fffbeb;
}
.stats-icon.purple {
color: #8b5cf6;
background: #f5f3ff;
}
.stats-label,
.stats-read {
color: #9ca3af;
font-size: 12px;
}
.stats-value {
margin: 3px 0;
color: #1f2937;
font-size: 18px;
font-weight: 700;
}
.filter-card,
.table-card {
background: #fff;
border: 1px solid #edf0f5;
border-radius: 14px;
}
.filter-card {
padding: 16px;
margin-bottom: 24px;
}
.filter-form {
display: flex;
flex-wrap: wrap;
align-items: center;
}
.filter-form ::v-deep .el-form-item {
margin-right: 14px;
margin-bottom: 0;
}
.filter-form ::v-deep .el-input,
.filter-form ::v-deep .el-select {
width: 170px;
}
.filter-form ::v-deep .el-input__inner {
border-radius: 8px;
}
.table-card {
overflow: hidden;
}
.article-table ::v-deep th {
background: #f8fafc;
color: #6b7280;
font-size: 14px;
font-weight: 600;
}
.article-title {
color: #1f2937;
font-weight: 600;
}
.type-tag {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 999px;
font-size: 12px;
}
.type-blue {
color: #2563eb;
background: #eff6ff;
}
.type-purple {
color: #7c3aed;
background: #f5f3ff;
}
.type-amber {
color: #d97706;
background: #fffbeb;
}
.type-violet {
color: #8b5cf6;
background: #f5f3ff;
}
.status-tag {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
}
.status-tag i {
width: 6px;
height: 6px;
border-radius: 50%;
}
.status-tag.is-published {
color: #16a34a;
}
.status-tag.is-published i {
background: #22c55e;
}
.status-tag.is-draft {
color: #9ca3af;
}
.status-tag.is-draft i {
background: #d1d5db;
}
.views-text {
display: inline-flex;
align-items: center;
gap: 4px;
color: #6b7280;
font-size: 12px;
}
.empty-read {
color: #d1d5db;
font-size: 12px;
}
.success-text {
color: #16a34a;
}
.warning-text {
color: #f59e0b;
}
.danger-text {
color: #f87171;
}
.table-pagination {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 20px;
border-top: 1px solid #edf0f5;
}
.pagination-info {
color: #6b7280;
font-size: 14px;
}
@media (max-width: 1200px) {
.stats-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 768px) {
.article-admin-page {
padding: 12px;
}
.stats-grid {
grid-template-columns: 1fr;
}
.table-pagination {
flex-direction: column;
gap: 10px;
align-items: flex-start;
}
}
</style>

View File

@ -28,7 +28,7 @@
type="button"
class="filter-tab"
:class="{ active: activeCategory === item.value }"
@click="activeCategory = item.value"
@click="handleCategoryChange(item.value)"
>
{{ item.label }}
</button>
@ -83,11 +83,22 @@
</div>
<div class="pagination">
<button type="button" class="pagination-btn"><i class="el-icon-arrow-left"></i></button>
<button type="button" class="pagination-btn active">1</button>
<button type="button" class="pagination-btn">2</button>
<button type="button" class="pagination-btn">3</button>
<button type="button" class="pagination-btn"><i class="el-icon-arrow-right"></i></button>
<button type="button" class="pagination-btn" :disabled="page <= 1" @click="changePage(page - 1)">
<i class="el-icon-arrow-left"></i>
</button>
<button
v-for="item in visiblePages"
:key="item"
type="button"
class="pagination-btn"
:class="{ active: page === item }"
@click="changePage(item)"
>
{{ item }}
</button>
<button type="button" class="pagination-btn" :disabled="page >= totalPages" @click="changePage(page + 1)">
<i class="el-icon-arrow-right"></i>
</button>
</div>
</div>
</section>
@ -95,137 +106,112 @@
</template>
<script>
import { reqNewsList } from '@/api/newsapi/newsapi'
export default {
name: 'NewsView',
data() {
return {
fallbackNewsImage: require('@/assets/image/news.jpg'),
activeCategory: 'all',
page: 1,
pageSize: 10,
total: 0,
filterTabs: [
{ label: '全部', value: 'all' },
{ label: '企业动态', value: 'company' },
{ label: '产品动态', value: 'product' },
{ label: '行业洞察', value: 'industry' },
{ label: '活动资讯', value: 'event' }
{ label: '企业动态', value: '企业动态' },
{ label: '产品动态', value: '产品动态' },
{ label: '行业洞察', value: '行业洞察' },
{ label: '活动资讯', value: '活动资讯' }
],
newsList: [
{
id: 0,
category: 'company',
tag: '企业动态',
tagColor: 'rgba(16,185,129,0.85)',
date: '2026.6.15',
title: '开元云随贸促会走访东盟',
desc: '开元云科技随贸促会广西分会经贸代表团密集出访越南、老挝,深度参与中国-东盟经贸合作以AI技术赋能区域产业升级推动智能体工厂落地东南亚市场。',
img: '',
featured: true,
content: '<p>开元云科技随贸促会广西分会经贸代表团密集出访越南、老挝,深度参与中国-东盟经贸合作。</p><p>在此次出访中开元云向东南亚市场全面展示了AI智能体工厂的工业级交付能力涵盖智慧工程、智能运维、能源管理等多个核心领域的技术方案。</p><p>此次东盟之行标志着开元云正式开启东南亚市场战略布局未来将持续深化与东盟各国在AI领域的合作以技术赋能区域产业升级。</p>'
},
{
id: 1,
category: 'company',
tag: '企业动态',
tagColor: 'rgba(16,185,129,0.85)',
date: '2026.05.17',
title: '开元云荣登福布斯中国人工智能商业落地示范企业',
desc: '凭借AI智能体工厂的工业级交付能力与央国企标杆案例开元云入选福布斯中国人工智能商业落地示范企业成为行业AI落地标杆。',
content: '<p>凭借AI智能体工厂的工业级交付能力与央国企标杆案例开元云入选福布斯中国人工智能商业落地示范企业。</p><p>此次评选从技术实力、商业落地、行业影响力等多维度综合评估,开元云凭借智能体工厂产品创新力成功跻身示范企业榜单。</p>'
},
{
id: 2,
category: 'product',
tag: '产品动态',
tagColor: 'rgba(99,102,241,0.85)',
date: '2026.05.10',
title: '智能体工厂2.0正式发布全面升级AI交付能力',
desc: '开元云智能体工厂2.0版本重磅上线新增多智能体协同编排、可视化工作流设计等核心功能AI应用交付效率提升300%。',
content: '<p>开元云智能体工厂2.0版本重磅上线,新增多智能体协同编排、可视化工作流设计等核心功能。</p><p>2.0版本支持复杂业务场景的智能体协作、拖拽式工作流构建和增强的文档智能处理能力。</p>'
},
{
id: 3,
category: 'industry',
tag: '行业洞察',
tagColor: 'rgba(13,148,136,0.85)',
date: '2026.04.28',
title: '2026年AI+能源行业趋势:从预测性维护到智能调度',
desc: 'AI技术正在重塑能源行业从发电设备的预测性维护到电网智能调度开元云深度解析行业变革趋势与落地实践。',
content: '<p>AI技术正在重塑能源行业从发电设备的预测性维护到电网智能调度行业正在进入智能化深水区。</p><p>基于机器视觉和传感器数据融合的预测性维护方案已在火电、风电领域广泛应用。</p>'
},
{
id: 4,
category: 'event',
tag: '活动资讯',
tagColor: 'rgba(245,158,11,0.85)',
date: '2026.04.15',
title: '开元云亮相2026中国人工智能大会',
desc: '开元云受邀出席2026中国人工智能大会现场展示智能体工厂最新技术成果与行业领袖共话AI产业化新路径。',
content: '<p>开元云受邀出席2026中国人工智能大会现场展示智能体工厂最新技术成果。</p><p>在大会主论坛上开元云系统阐述了AI技术从实验创新到工业级交付的方法论。</p>'
},
{
id: 5,
category: 'company',
tag: '企业动态',
tagColor: 'rgba(16,185,129,0.85)',
date: '2026.03.22',
title: '开元云与南宁林业局达成智慧林业战略合作',
desc: '开元云携手南宁林业局打造林业空间采伐智审系统融合GIS空间分析、卫星遥感等多源数据审批周期从数天压缩至分钟级。',
img: '',
content: '<p>开元云携手南宁林业局打造林业空间采伐智审系统融合GIS空间分析、卫星遥感等多源数据。</p><p>项目上线后,采伐审批效率显著提升,成为智慧林业标杆案例。</p>'
},
{
id: 6,
category: 'product',
tag: '产品动态',
tagColor: 'rgba(236,72,153,0.85)',
date: '2026.03.08',
title: '机器视觉检测平台升级,支持工业质检全场景覆盖',
desc: '开元云机器视觉检测平台全面升级,新增缺陷分类、尺寸测量、表面检测三大能力模块,覆盖制造业全场景质检需求。',
content: '<p>开元云机器视觉检测平台全面升级,新增缺陷分类、尺寸测量、表面检测三大能力模块。</p><p>该平台已在汽车零部件、3C电子、半导体封装等行业完成部署。</p>'
},
{
id: 7,
category: 'industry',
tag: '行业洞察',
tagColor: 'rgba(37,99,235,0.85)',
date: '2026.02.20',
title: 'AI+教育:大模型如何重构个性化学习路径',
desc: '从知识图谱构建到学习路径智能推荐大模型正在从根本上改变教育模式。开元云分享AI+教育的最新落地实践与思考。',
content: '<p>从知识图谱构建到学习路径智能推荐,大模型正在从根本上改变教育模式。</p><p>基于大模型的自适应学习系统可以实时评估学习者知识掌握程度,动态调整学习路径。</p>'
},
{
id: 8,
category: 'event',
tag: '活动资讯',
tagColor: 'rgba(124,58,237,0.85)',
date: '2026.02.05',
title: '开元云荣获2025年度AI创新企业TOP50',
desc: '在2025年度人工智能创新企业评选中开元云凭借卓越的技术创新能力和丰富的行业落地经验成功入选AI创新企业TOP50。',
content: '<p>在2025年度人工智能创新企业评选中开元云凭借卓越的技术创新能力和丰富的行业落地经验成功入选。</p>'
},
{
id: 9,
category: 'company',
tag: '企业动态',
tagColor: 'rgba(234,88,12,0.85)',
date: '2026.01.18',
title: '开元云完成B轮融资加速AI智能体产业布局',
desc: '开元云科技宣布完成B轮融资融资金额将用于加速AI智能体工厂产品研发、行业解决方案深化及海外市场拓展。',
content: '<p>开元云科技宣布完成B轮融资融资金额将用于加速AI智能体工厂产品研发、行业解决方案深化及海外市场拓展。</p>'
}
]
newsList: []
}
},
computed: {
featuredNews() {
return this.newsList.find(item => item.featured)
return this.newsList[0]
},
filteredNewsList() {
return this.newsList.filter(item => !item.featured && (this.activeCategory === 'all' || item.category === this.activeCategory))
return this.newsList.slice(1)
},
totalPages() {
return Math.max(Math.ceil(this.total / this.pageSize), 1)
},
visiblePages() {
return Array.from({ length: Math.min(this.totalPages, 3) }, (_, index) => index + 1)
}
},
created() {
this.getNewsList()
},
methods: {
goAbout() {
this.$router.push('/homePage/about')
},
handleCategoryChange(value) {
this.activeCategory = value
this.page = 1
this.getNewsList()
},
changePage(page) {
if (page < 1 || page > this.totalPages || page === this.page) return
this.page = page
this.getNewsList()
},
buildNewsParams() {
return {
url_link: window.location.href.split('#')[0] || window.location.href,
current_page: String(this.page),
page_size: String(this.pageSize),
article_type: this.activeCategory === 'all' ? '' : this.activeCategory,
title: ''
}
},
getTagColor(type) {
const map = {
企业动态: 'rgba(16,185,129,0.85)',
产品动态: 'rgba(99,102,241,0.85)',
行业洞察: 'rgba(13,148,136,0.85)',
活动资讯: 'rgba(245,158,11,0.85)'
}
return map[type] || 'rgba(99,102,241,0.85)'
},
normalizeImageUrl(url) {
if (!url) return ''
if (/^(https?:|blob:|data:|\/\/)/.test(url)) return url
return `${window.location.origin}/idfile?path=${url}`
},
normalizeNewsItem(row) {
const type = row.article_type || '企业动态'
return {
id: row.id,
category: type,
tag: type,
tagColor: this.getTagColor(type),
date: row.publish_time || '',
title: row.title || '未命名文章',
desc: row.summary || '',
img: this.normalizeImageUrl(row.cover_img || ''),
views: row.read_count || 0
}
},
async getNewsList() {
try {
const res = await reqNewsList(this.buildNewsParams())
if (res && res.status === true) {
this.newsList = Array.isArray(res.data) ? res.data.map(item => this.normalizeNewsItem(item)) : []
const pagination = res.pagination || {}
this.total = Number(pagination.total || this.newsList.length)
this.page = Number(pagination.current_page || this.page)
this.pageSize = Number(pagination.page_size || this.pageSize)
return
}
this.newsList = []
this.total = 0
} catch (error) {
this.newsList = []
this.total = 0
this.$message.error('新闻列表加载失败')
}
}
}
}